diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index d7f41e053b..37d9151424 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -224,6 +224,8 @@ Features: - Inline quick entry creation - The quick-entry GitHub icon is a per-task tracking override: leave it untouched to use the project default, turn it on to opt the next task into tracking when the default is off, or turn it off to opt the next task out when the default is on. - PR/issue badges with live updates +- Planning cards and List rows/cards show the same active border and pulsing **Planning** badge when fresh planner activity reaches the live log stream, including the brief status-null transition before the authoritative task row refreshes. The transient indicator clears on that authoritative refresh, so completed planning does not remain active. + - GitLab tracking badges on task cards for linked GitLab project issues, group issues, and merge requests; stale GitLab metadata uses a warning-colored badge while GitHub badges remain unchanged. - GitHub provenance marker on task cards imported from GitHub (`sourceType: github_import`), shown in the footer with other external-source metadata - Task card header meta badges group priority and fast mode in the header; priority badges include the shared urgency glyph/color language (low blue/info, high amber/warning, urgent red/error) while agent-created provenance renders in a dedicated bottom-left row ahead of workflow identity so the ID/status/actions header does not wrap on narrow cards. Agent labels prefer `sourceMetadata.agentName` over raw agent IDs. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b0431c0ec4..4555c11ae9 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1443,6 +1443,12 @@ export interface Task { */ customFields?: Record; status?: string; + /** + * FNXC:TaskActivity 2026-07-28-12:00: + * Dashboard-only signal from a fresh planner agent-log SSE entry. It is never + * persisted or sent to the server; authoritative task updates clear it. + */ + recentAgentActivityAt?: string; /** ID of the in-progress task whose file scope overlaps with this task, * causing the scheduler to defer it. Set when the scheduler queues * the task due to file-scope overlap; cleared (set to `undefined`) diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 228969db31..fbe1da8b12 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -2669,9 +2669,13 @@ export function ListView({ const isPaused = !isDoneColumn && task.paused === true; const isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs); const isAgentActive = isTaskAgentActive(task, { globalPaused, isStuck: isStuckState }); - // FNXC:TaskStatusBadge 2026-07-16-12:00: FN-8170 keeps mobile and table list status rendering aligned with TaskCard through the shared Todo/In Progress planning suppression predicate. - const hasStatus = typeof visualStatus === "string" - && visualStatus.trim().length > 0 + // FNXC:TaskStatusBadge 2026-07-28-12:00: FN-8300 renders the same transient Planning badge as TaskCard so fresh planner logs never make grouped-list cards appear idle. + const isTransientPlannerActive = task.column === "triage" + && !visualStatus + && Boolean(task.recentAgentActivityAt) + && isAgentActive; + const hasStatus = (typeof visualStatus === "string" && visualStatus.trim().length > 0 + || isTransientPlannerActive) && !shouldSuppressPlanningStatusBadge({ status: visualStatus, column: task.column }); const isReviewBudgetExhausted = isReviewBudgetExhaustedApproval(task); const planReviewRunning = isPlanReviewRunning(task); @@ -2732,11 +2736,14 @@ export function ListView({ {isReviewBudgetExhausted ? t("tasks.reviewBudgetExhausted", "Review budget exhausted") - : getTaskStatusLabel(visualStatus ?? "", t)} + : isTransientPlannerActive + ? t("tasks.statusPlanning", "Planning") + : getTaskStatusLabel(visualStatus ?? "", t)} ) : null} {planReviewRunning && isAgentActive && ( @@ -2886,7 +2893,11 @@ export function ListView({ const isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs); const isAgentActive = isTaskAgentActive(task, { globalPaused, isStuck: isStuckState }); const isReviewBudgetExhausted = isReviewBudgetExhaustedApproval(task); - const showStatusBadge = Boolean(visualStatus) + const isTransientPlannerActive = task.column === "triage" + && !visualStatus + && Boolean(task.recentAgentActivityAt) + && isAgentActive; + const showStatusBadge = (Boolean(visualStatus) || isTransientPlannerActive) && !shouldSuppressPlanningStatusBadge({ status: visualStatus, column: task.column }); const planReviewRunning = isPlanReviewRunning(task); const isDragging = draggingTaskId === task.id; @@ -2958,11 +2969,14 @@ export function ListView({ isAgentActive ? " pulsing" : "" }`} title={isReviewBudgetExhausted ? t("tasks.awaitingApprovalPlanReviewReplanCapTitle", "Plan Review requested revisions repeatedly without converging. Approve the current plan to proceed, or reject to regenerate it.") : undefined} + aria-label={isTransientPlannerActive ? t("tasks.statusPlanning", "Planning") : undefined} data-testid={isReviewBudgetExhausted ? `list-review-budget-exhausted-${task.id}` : undefined} > {isReviewBudgetExhausted ? t("tasks.reviewBudgetExhausted", "Review budget exhausted") - : getTaskStatusLabel(visualStatus ?? "", t)} + : isTransientPlannerActive + ? t("tasks.statusPlanning", "Planning") + : getTaskStatusLabel(visualStatus ?? "", t)} ) : ( - diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index bc107c76ef..dbc7c2adf7 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -780,6 +780,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo previousTask.updatedAt === nextTask.updatedAt && previousTask.createdAt === nextTask.createdAt && previousTask.status === nextTask.status && + previousTask.recentAgentActivityAt === nextTask.recentAgentActivityAt && previousTask.priority === nextTask.priority && previousTask.executionMode === nextTask.executionMode && previousTask.paused === nextTask.paused && @@ -2884,11 +2885,15 @@ function TaskCardComponent({ * card. The oversight-level badge (`showOversightBadge`) is untouched. */ /* - FNXC:TaskStatusBadge 2026-07-16-12:00: - FN-8170 shares this predicate with ListView so stale planning status never produces a Todo/In Progress board badge or its otherwise-empty header wrapper. + FNXC:TaskStatusBadge 2026-07-28-12:00: + FN-8300 keeps Planning cards visually consistent with their fresh planner-log timeline: a transient client signal renders the existing pulsing Planning badge even while the authoritative status is null. ListView uses the same condition on both render paths. */ + const isTransientPlannerActive = task.column === "triage" + && !visualStatus + && Boolean(task.recentAgentActivityAt) + && isAgentActive; const showStatusBadge = !isPaused - && Boolean(visualStatus) + && (Boolean(visualStatus) || isTransientPlannerActive) && visualStatus !== "queued" && !shouldSuppressPlanningStatusBadge({ status: visualStatus, column: task.column }); const hasCardMetaBadges = showPriorityBadge @@ -3046,6 +3051,7 @@ function TaskCardComponent({ ) : undefined } + aria-label={isTransientPlannerActive ? t("tasks.statusPlanning", "Planning") : undefined} data-testid={isAwaitingApproval ? `card-awaiting-approval-${task.id}` : undefined} data-awaiting-approval-reason={isAwaitingApproval ? (task.awaitingApprovalReason ?? "manual") : undefined} > @@ -3057,9 +3063,11 @@ function TaskCardComponent({ ? t("tasks.awaitingApproval", "Awaiting Approval") : isAwaitingInput ? t("tasks.needsInput", "Needs input") - : visualStatus === "merging-fix" - ? t("tasks.statusMergingFix", "Merging fixes…") - : getTaskStatusLabel(visualStatus!, t)} + : isTransientPlannerActive + ? t("tasks.statusPlanning", "Planning") + : visualStatus === "merging-fix" + ? t("tasks.statusMergingFix", "Merging fixes…") + : getTaskStatusLabel(visualStatus!, t)} )} {planReviewRunning && isAgentActive && ( diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index 1eead0968c..f832ffed33 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -460,6 +460,44 @@ describe("ListView", () => { viewportSpy.mockRestore(); }); + it("renders the active Planning badge for a fresh status-null triage card in grouped mobile cards", () => { + const viewportSpy = mockMobileViewport(); + try { + renderListView({ + tasks: [createMockTask({ + id: "FN-8300-mobile", + status: null as any, + recentAgentActivityAt: new Date().toISOString(), + })], + }); + + const card = screen.getByText("FN-8300-mobile").closest(".list-card") as HTMLElement; + expect(card).toHaveClass("agent-active"); + expect(within(card).getByLabelText("Planning")).toHaveClass("list-status-badge", "pulsing"); + } finally { + viewportSpy.mockRestore(); + } + }); + + it("renders the active Planning badge for a fresh status-null triage card in desktop table rows", () => { + const viewportSpy = mockDesktopViewport(); + try { + renderListView({ + tasks: [createMockTask({ + id: "FN-8300-desktop", + status: null as any, + recentAgentActivityAt: new Date().toISOString(), + })], + }); + + const row = screen.getByText("FN-8300-desktop").closest("tr") as HTMLElement; + expect(row).toHaveClass("agent-active"); + expect(within(row).getByLabelText("Planning")).toHaveClass("list-status-badge", "pulsing"); + } finally { + viewportSpy.mockRestore(); + } + }); + it("falls back malformed task columns to Planning group instead of crashing", () => { const malformedTask = { ...createMockTask({ id: "FN-404" }), diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index 9d0ebab099..f33aab2e9e 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -2456,10 +2456,25 @@ describe("TaskCard", () => { expect(headerBadges.contains(badge)).toBe(true); }); - it("does not render a status badge when task.status is falsy", () => { + it("renders an active Planning badge when a status-null triage card has fresh planner activity", () => { + const recentAgentActivityAt = new Date().toISOString(); const { container } = render( - , + , ); + + expect(container.querySelector(".card")).toHaveClass("agent-active"); + expect(screen.getByLabelText("Planning")).toHaveClass("card-status-badge", "pulsing"); + }); + + it("does not render a status badge when a status-null triage card has no fresh planner activity", () => { + const { container } = render( + , + ); + expect(container.querySelector(".card")).not.toHaveClass("agent-active"); expect(container.querySelector(".card-status-badge")).toBeNull(); }); diff --git a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts index 55b7b47f31..c801d5bf0a 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts @@ -3676,6 +3676,60 @@ describe("useTasks", () => { vi.useRealTimers(); }); + it("marks fresh planner logs transiently active and clears the signal on an authoritative update", async () => { + const initialTask = createMockTask({ + column: "triage", + status: null, + updatedAt: "2026-07-28T12:00:00.000Z", + }); + mockFetchTasks.mockResolvedValueOnce([initialTask]); + const { result } = renderHook(() => useTasks()); + + 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"); + + act(() => { + MockEventSource.instances[0]._emit("task:updated", { + ...initialTask, + updatedAt: "2026-07-28T12:00:02.000Z", + }); + }); + expect(result.current.tasks[0]?.recentAgentActivityAt).toBeUndefined(); + }); + + it("keeps clearing in-review stalls when a fresh agent log arrives", async () => { + const initialTask = createMockTask({ + column: "in-review", + inReviewStall: { + code: "merge-blocker", + reason: "Merge is blocked", + observedAt: "2026-07-28T12:00:00.000Z", + }, + updatedAt: "2026-07-28T12:00:00.000Z", + }); + mockFetchTasks.mockResolvedValueOnce([initialTask]); + const { result } = renderHook(() => useTasks()); + + 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: "text", + agent: "reviewer", + }); + }); + expect(result.current.tasks[0]?.inReviewStall).toBeUndefined(); + }); + it("does not trigger onReconnect refetch after sseEnabled flips to false", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); const { rerender } = renderHook( diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index f2e6fb1b66..5f1a8e4276 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -32,17 +32,17 @@ function filterActiveTasks(tasks: Task[]): Task[] { type AgentLogActivityEvent = Pick; -function clearInReviewStallForFreshAgentLog(task: Task, entry: AgentLogActivityEvent): Task { - if (task.id !== entry.taskId || task.column !== "in-review") return task; +function hasFreshAgentLog(task: Task, entry: AgentLogActivityEvent): boolean { + if (task.id !== entry.taskId) return false; const logTimestampMs = Date.parse(entry.timestamp); const taskUpdatedAtMs = Date.parse(task.updatedAt); - if ( - Number.isFinite(logTimestampMs) && - Number.isFinite(taskUpdatedAtMs) && - logTimestampMs <= taskUpdatedAtMs - ) { - return task; - } + return Number.isFinite(logTimestampMs) + && Number.isFinite(taskUpdatedAtMs) + && logTimestampMs > taskUpdatedAtMs; +} + +function clearInReviewStallForFreshAgentLog(task: Task, entry: AgentLogActivityEvent): Task { + if (task.column !== "in-review" || !hasFreshAgentLog(task, entry)) return task; if (!task.inReviewStall && !task.inReviewStalled && !task.stalledReview) return task; /* @@ -57,6 +57,27 @@ function clearInReviewStallForFreshAgentLog(task: Task, entry: AgentLogActivityE }; } +function addRecentPlannerActivityForFreshAgentLog(task: Task, entry: AgentLogActivityEvent): Task { + if ( + task.column !== "triage" + || task.status === "planning" + || entry.agent !== "triage" + || !hasFreshAgentLog(task, entry) + ) { + return task; + } + + /* + FNXC:TaskActivity 2026-07-28-12:00: + A Planning card's border and pulsing badge must agree with the live planner + timeline. A fresh triage log can arrive before its status row, so retain this + client-only render signal until an authoritative task update replaces the row. + */ + return task.recentAgentActivityAt === entry.timestamp + ? task + : { ...task, recentAgentActivityAt: entry.timestamp }; +} + /** * Compare two ISO timestamp strings. * Returns positive if a is newer than b, negative if b is newer, 0 if equal. @@ -687,8 +708,9 @@ export function useTasks(options?: UseTasksOptions) { let changed = false; const next = prev.map((task) => { const cleared = clearInReviewStallForFreshAgentLog(task, entry); - if (cleared !== task) changed = true; - return cleared; + const updated = addRecentPlannerActivityForFreshAgentLog(cleared, entry); + if (updated !== task) changed = true; + return updated; }); return changed ? next : prev; }); diff --git a/packages/dashboard/app/utils/__tests__/taskActivity.test.ts b/packages/dashboard/app/utils/__tests__/taskActivity.test.ts index 4a7275055d..e07cbe2799 100644 --- a/packages/dashboard/app/utils/__tests__/taskActivity.test.ts +++ b/packages/dashboard/app/utils/__tests__/taskActivity.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { Task } from "@fusion/core"; import { ACTIVE_STATUSES, isTaskAgentActive } from "../taskActivity"; @@ -32,6 +32,8 @@ function taskWithRunningWorkflowStep(overrides: Partial = {}): Task { } describe("isTaskAgentActive", () => { + afterEach(() => vi.useRealTimers()); + it("uses the canonical set for every active phase", () => { expect([...ACTIVE_STATUSES]).toEqual([ "planning", "researching", "executing", "finalizing", "merging", "merging-pr", "merging-fix", "reviewing", "landing", @@ -50,6 +52,18 @@ describe("isTaskAgentActive", () => { expect(isTaskAgentActive(makeTask())).toBe(false); }); + it("uses a fresh client-only planner log signal for a status-null triage card", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T12:00:30.000Z")); + + expect(isTaskAgentActive(makeTask({ + recentAgentActivityAt: "2026-07-28T12:00:00.000Z", + }))).toBe(true); + expect(isTaskAgentActive(makeTask({ + recentAgentActivityAt: "2026-07-28T11:59:00.000Z", + }))).toBe(false); + }); + it.each([ ["queued task status", taskWithRunningWorkflowStep({ status: "queued" }), {}], ["paused status", taskWithRunningWorkflowStep({ status: "paused" }), {}], @@ -65,6 +79,12 @@ describe("isTaskAgentActive", () => { ["render queue", taskWithRunningWorkflowStep(), { queued: true }], ["derived stuck", taskWithRunningWorkflowStep(), { isStuck: true }], ["global pause", taskWithRunningWorkflowStep(), { globalPaused: true }], + ["failed status with fresh planner log", makeTask({ status: "failed", recentAgentActivityAt: new Date().toISOString() }), {}], + ["paused task with fresh planner log", makeTask({ paused: true, recentAgentActivityAt: new Date().toISOString() }), {}], + ["done column with fresh planner log", makeTask({ column: "done", recentAgentActivityAt: new Date().toISOString() }), {}], + ["archived column with fresh planner log", makeTask({ column: "archived", recentAgentActivityAt: new Date().toISOString() }), {}], + ["awaiting approval with fresh planner log", makeTask({ status: "awaiting-approval", recentAgentActivityAt: new Date().toISOString() }), {}], + ["awaiting user input with fresh planner log", makeTask({ status: "awaiting-user-input", recentAgentActivityAt: new Date().toISOString() }), {}], ] as const)("rejects %s before running workflow activity", (_name, task, options) => { expect(isTaskAgentActive(task, options)).toBe(false); }); diff --git a/packages/dashboard/app/utils/taskActivity.ts b/packages/dashboard/app/utils/taskActivity.ts index 990c3bf992..ef76c30c1e 100644 --- a/packages/dashboard/app/utils/taskActivity.ts +++ b/packages/dashboard/app/utils/taskActivity.ts @@ -14,6 +14,8 @@ export const ACTIVE_STATUSES = new Set([ "landing", ]); +export const RECENT_PLANNER_ACTIVITY_WINDOW_MS = 60_000; + export interface TaskAgentActivityOptions { globalPaused?: boolean; queued?: boolean; @@ -24,12 +26,15 @@ export interface TaskAgentActivityOptions { FNXC:TaskActivity 2026-07-16-00:00: FN-8055 makes the agent-active border and pulsing badges represent the same ground truth: an agent is working now. Reject render-context global pause, queue, and derived freshness-stuck gates before checking activity, then combine the engine's column-aware active window with canonical phase statuses and the running unified workflow item that drives progress badges. +FNXC:TaskActivity 2026-07-28-12:00: +FN-8300 also honors a bounded, client-only fresh planner-log timestamp for triage cards. The log stream can arrive before the authoritative planning-status row; this render-only fallback closes that window without changing routing/model locks. + Stuck-killed and both terminal columns are never active, even when stale execution status or workflow-step data remains on the task. Model-resolution and routing locks intentionally import only ACTIVE_STATUSES and retain their status-or-in-progress policy; using this rendering predicate there would change lock behavior during status-null workflow steps. */ export function isTaskAgentActive( - task: Pick, + task: Pick, options: TaskAgentActivityOptions = {}, ): boolean { const status = task.status; @@ -53,7 +58,15 @@ export function isTaskAgentActive( return false; } + const recentPlannerActivityAtMs = Date.parse(task.recentAgentActivityAt ?? ""); + const nowMs = Date.now(); + const hasFreshPlannerActivity = task.column === "triage" + && Number.isFinite(recentPlannerActivityAtMs) + && nowMs - recentPlannerActivityAtMs >= 0 + && nowMs - recentPlannerActivityAtMs <= RECENT_PLANNER_ACTIVITY_WINDOW_MS; + return task.column === "in-progress" || ACTIVE_STATUSES.has(status ?? "") || + hasFreshPlannerActivity || getUnifiedTaskProgress(task).items.some((item) => item.status === "running"); }