diff --git a/packages/dashboard/app/components/AgentDetailView.tsx b/packages/dashboard/app/components/AgentDetailView.tsx
index 51935bcd0c..2e2c958425 100644
--- a/packages/dashboard/app/components/AgentDetailView.tsx
+++ b/packages/dashboard/app/components/AgentDetailView.tsx
@@ -31,6 +31,7 @@ import { useConfirm } from "../hooks/useConfirm";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { AgentAvatar } from "./AgentAvatar";
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
+import { AgentTaskBadge } from "./AgentTaskBadge";
import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal";
import { AgentPermissionPolicyEditor } from "./AgentPermissionPolicyEditor";
import { useFavorites } from "../hooks/useFavorites";
@@ -1028,7 +1029,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
) : (
diff --git a/packages/dashboard/app/components/AgentListModal.tsx b/packages/dashboard/app/components/AgentListModal.tsx
index f495788b74..c7f3fb68c7 100644
--- a/packages/dashboard/app/components/AgentListModal.tsx
+++ b/packages/dashboard/app/components/AgentListModal.tsx
@@ -15,6 +15,7 @@ import type { AgentHealthStatus } from "../utils/agentHealth";
import { useConfirm } from "../hooks/useConfirm";
import { AgentAvatar } from "./AgentAvatar";
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
+import { AgentTaskBadge } from "./AgentTaskBadge";
interface AgentListModalProps {
isOpen: boolean;
@@ -584,7 +585,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
{agent.taskId && (
)}
{agent.lastHeartbeatAt && (
diff --git a/packages/dashboard/app/components/AgentTaskBadge.tsx b/packages/dashboard/app/components/AgentTaskBadge.tsx
new file mode 100644
index 0000000000..fd18b8f67d
--- /dev/null
+++ b/packages/dashboard/app/components/AgentTaskBadge.tsx
@@ -0,0 +1,28 @@
+import type { ColumnId } from "@fusion/core";
+import { useTranslation } from "react-i18next";
+import { useColumnLabel } from "../i18n/labels";
+
+const UNRESOLVED_AGENT_TASK_COLUMN = "unresolved";
+
+interface AgentTaskBadgeProps {
+ taskId: string;
+ taskColumn?: string;
+}
+
+/*
+ * FNXC:AgentTaskStateDrift 2026-06-27-16:20:
+ * Agent task badges include the linked task column to disambiguate legitimate triage/queued linkage from execution drift.
+ *
+ * FNXC:AgentTaskStateDrift 2026-06-27-17:08:
+ * Unresolved linked tasks need an explicit badge suffix so missing/deleted tasks do not look like a merely un-enriched response.
+ */
+export function AgentTaskBadge({ taskId, taskColumn }: AgentTaskBadgeProps) {
+ const columnLabel = useColumnLabel();
+ const { t } = useTranslation("app");
+
+ if (!taskColumn || taskColumn === UNRESOLVED_AGENT_TASK_COLUMN) {
+ return <>{taskId} · {t("agents.taskColumnUnresolved", "Unresolved task")}>;
+ }
+
+ return <>{taskId} · {columnLabel(taskColumn as ColumnId)}>;
+}
diff --git a/packages/dashboard/app/components/AgentsView.tsx b/packages/dashboard/app/components/AgentsView.tsx
index b19d6dcb86..2115bcc9e0 100644
--- a/packages/dashboard/app/components/AgentsView.tsx
+++ b/packages/dashboard/app/components/AgentsView.tsx
@@ -37,6 +37,7 @@ import {
} from "./agentsOrgChartLayout";
import { AgentAvatar } from "./AgentAvatar";
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
+import { AgentTaskBadge } from "./AgentTaskBadge";
export interface AgentsViewProps {
addToast: (message: string, type?: "success" | "error") => void;
@@ -1886,7 +1887,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
{agent.taskId && (
diff --git a/packages/dashboard/app/components/__tests__/ActiveAgentsPanel.test.tsx b/packages/dashboard/app/components/__tests__/ActiveAgentsPanel.test.tsx
index ef08090dd7..641b1aaafb 100644
--- a/packages/dashboard/app/components/__tests__/ActiveAgentsPanel.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ActiveAgentsPanel.test.tsx
@@ -300,25 +300,56 @@ describe("ActiveAgentsPanel", () => {
expect(container.firstChild).toBeNull();
});
- it("displays agent name and task badge", async () => {
+ it("displays agent task badges with column context and unresolved fallback", async () => {
mockUseLiveTranscript.mockReturnValue({
entries: [],
isConnected: false,
});
- const mockAgent: Agent = {
- id: "agent-001",
- name: "My Agent",
- role: "executor",
- state: "running",
- taskId: "FN-042",
- lastHeartbeatAt: new Date().toISOString(),
- } as Agent;
+ const agents: Agent[] = [
+ {
+ id: "agent-001",
+ name: "Triage Agent",
+ role: "executor",
+ state: "running",
+ taskId: "FN-TRIAGE",
+ taskColumn: "triage",
+ lastHeartbeatAt: new Date().toISOString(),
+ } as Agent,
+ {
+ id: "agent-002",
+ name: "Progress Agent",
+ role: "executor",
+ state: "running",
+ taskId: "FN-PROGRESS",
+ taskColumn: "in-progress",
+ lastHeartbeatAt: new Date().toISOString(),
+ } as Agent,
+ {
+ id: "agent-003",
+ name: "Bare Agent",
+ role: "executor",
+ state: "running",
+ taskId: "FN-BARE",
+ taskColumn: "unresolved",
+ lastHeartbeatAt: new Date().toISOString(),
+ } as Agent,
+ {
+ id: "agent-004",
+ name: "No Task Agent",
+ role: "executor",
+ state: "active",
+ lastHeartbeatAt: new Date().toISOString(),
+ } as Agent,
+ ];
- render(
);
+ const { container } = render(
);
- expect(screen.getByText("My Agent")).toBeInTheDocument();
- expect(screen.getByText("FN-042")).toBeInTheDocument();
+ expect(screen.getByText("Triage Agent")).toBeInTheDocument();
+ expect(screen.getByText((_, el) => el?.textContent === "FN-TRIAGE · Planning")).toBeInTheDocument();
+ expect(screen.getByText((_, el) => el?.textContent === "FN-PROGRESS · In Progress")).toBeInTheDocument();
+ expect(screen.getByText((_, el) => el?.textContent === "FN-BARE · Unresolved task")).toBeInTheDocument();
+ expect(container.querySelectorAll(".live-agent-task")).toHaveLength(3);
});
it("calls onAgentSelect with agent ID when card is clicked", async () => {
diff --git a/packages/dashboard/app/components/__tests__/AgentDetailView.core.test.tsx b/packages/dashboard/app/components/__tests__/AgentDetailView.core.test.tsx
index 59a1bdeac0..410d6d9819 100644
--- a/packages/dashboard/app/components/__tests__/AgentDetailView.core.test.tsx
+++ b/packages/dashboard/app/components/__tests__/AgentDetailView.core.test.tsx
@@ -270,6 +270,71 @@ it("uses global design tokens instead of component-local aliases", async () => {
expect(stylesContent).toMatch(/--card-hover:/);
});
+it("displays linked task column context in header and current work", async () => {
+ mockFetchAgent.mockResolvedValueOnce(createMockAgent({ taskId: "FN-TRIAGE", taskColumn: "triage" }));
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getAllByText((_, el) => el?.textContent === "FN-TRIAGE · Planning").length).toBeGreaterThanOrEqual(2);
+ });
+});
+
+it("displays in-progress linked task column context", async () => {
+ mockFetchAgent.mockResolvedValueOnce(createMockAgent({ taskId: "FN-PROGRESS", taskColumn: "in-progress" }));
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getAllByText((_, el) => el?.textContent === "FN-PROGRESS · In Progress").length).toBeGreaterThanOrEqual(2);
+ });
+});
+
+it("displays unresolved linked task context when column enrichment is missing", async () => {
+ mockFetchAgent.mockResolvedValueOnce(createMockAgent({ taskId: "FN-BARE", taskColumn: undefined }));
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getAllByText((_, el) => el?.textContent === "FN-BARE · Unresolved task").length).toBeGreaterThanOrEqual(2);
+ });
+});
+
+it("does not render task badge shells without a linked task", async () => {
+ mockFetchAgent.mockResolvedValueOnce(createMockAgent({ taskId: undefined, taskColumn: undefined }));
+
+ const { container } = render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("No active assignment")).toBeInTheDocument();
+ });
+ expect(container.querySelector(".task-badge")).toBeNull();
+});
+
it("displays agent name in header after loading", async () => {
render(
{
expect(window.getComputedStyle(footerEl).flexShrink).toBe("0");
});
+ it("shows mobile task column context without empty task shells (FN-7139)", async () => {
+ mockFetchAgent.mockResolvedValueOnce(createMockAgent({ taskId: "FN-MOBILE", taskColumn: "in-progress" }));
+
+ const { container } = render();
+
+ await waitFor(() => {
+ expect(screen.getAllByText((_, el) => el?.textContent === "FN-MOBILE · In Progress").length).toBeGreaterThanOrEqual(2);
+ });
+ expect(container.querySelector(".agent-detail-content")).toBeTruthy();
+ expect(container.querySelector(".task-badge")?.textContent).toContain("FN-MOBILE · In Progress");
+ });
+
it("tabs accept horizontal touch panning and stay non-shrinking on mobile (FN-6450, FN-6865)", async () => {
render();
diff --git a/packages/dashboard/app/components/__tests__/AgentListModal.test.tsx b/packages/dashboard/app/components/__tests__/AgentListModal.test.tsx
index 3373e9a3b2..c314251dac 100644
--- a/packages/dashboard/app/components/__tests__/AgentListModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/AgentListModal.test.tsx
@@ -248,8 +248,15 @@ describe("AgentListModal", () => {
});
});
- it("displays task ID when agent is working on a task", async () => {
- render(
+ it("displays task ID with column context when agent is working on a task", async () => {
+ mockFetchAgents.mockResolvedValue([
+ { ...mockAgents[0], id: "agent-triage", name: "Triage Agent", taskId: "FN-TRIAGE", taskColumn: "triage", state: "active" as AgentState },
+ { ...mockAgents[1], id: "agent-progress", name: "Progress Agent", taskId: "FN-PROGRESS", taskColumn: "in-progress", state: "running" as AgentState },
+ { ...mockAgents[2], id: "agent-bare", name: "Bare Agent", taskId: "FN-BARE", taskColumn: "unresolved" },
+ { ...mockAgents[3], id: "agent-none", name: "No Task Agent" },
+ ]);
+
+ const { container } = render(
{
);
await waitFor(() => {
- expect(screen.getByText("FN-001")).toBeTruthy();
+ expect(screen.getAllByText((_, el) => el?.textContent === "FN-TRIAGE · Planning").length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText((_, el) => el?.textContent === "FN-PROGRESS · In Progress").length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText((_, el) => el?.textContent === "FN-BARE · Unresolved task").length).toBeGreaterThanOrEqual(1);
});
+ expect(container.querySelectorAll(".agent-task").length).toBe(3);
});
it("shows empty state when no agents exist", async () => {
diff --git a/packages/dashboard/app/components/__tests__/AgentsView.test.tsx b/packages/dashboard/app/components/__tests__/AgentsView.test.tsx
index 19994cfd48..135bc969a7 100644
--- a/packages/dashboard/app/components/__tests__/AgentsView.test.tsx
+++ b/packages/dashboard/app/components/__tests__/AgentsView.test.tsx
@@ -826,10 +826,29 @@ describe("AgentsView", () => {
});
});
- it("displays agent task when working on one", async () => {
+ it("displays agent task with column context when enriched", async () => {
+ mockFetchAgents.mockResolvedValue([
+ { ...mockAgents[0], id: "agent-triage", name: "Triage Agent", taskId: "FN-TRIAGE", taskColumn: "triage", state: "active" as AgentState },
+ { ...mockAgents[1], id: "agent-progress", name: "Progress Agent", taskId: "FN-PROGRESS", taskColumn: "in-progress", state: "running" as AgentState },
+ { ...mockAgents[2], id: "agent-bare", name: "Bare Agent", taskId: "FN-BARE", taskColumn: "unresolved" },
+ { ...mockAgents[3], id: "agent-none", name: "No Task Agent" },
+ ]);
+ mockFetchAgentStats.mockResolvedValue({ total: 4, byState: { active: 1, running: 1 }, byRole: { executor: 2 } });
+
+ const { container } = render();
+
+ await waitFor(() => {
+ expect(screen.getAllByText((_, el) => el?.textContent === "FN-TRIAGE · Planning").length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText((_, el) => el?.textContent === "FN-PROGRESS · In Progress").length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText((_, el) => el?.textContent === "FN-BARE · Unresolved task").length).toBeGreaterThanOrEqual(1);
+ });
+ expect(container.querySelectorAll(".agent-task").length).toBeGreaterThanOrEqual(3);
+ });
+
+ it("displays unresolved context when task column is missing", async () => {
render();
await waitFor(() => {
- expect(screen.getAllByText("FN-001").length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText((_, el) => el?.textContent === "FN-001 · Unresolved task").length).toBeGreaterThanOrEqual(1);
});
});
diff --git a/packages/dashboard/src/__tests__/routes-agents.test.ts b/packages/dashboard/src/__tests__/routes-agents.test.ts
index 14dbd8e615..58a7cdbb85 100644
--- a/packages/dashboard/src/__tests__/routes-agents.test.ts
+++ b/packages/dashboard/src/__tests__/routes-agents.test.ts
@@ -2035,7 +2035,7 @@ describe("POST /api/ai/draft-goal-description", () => {
expect(res.body).toEqual({
description: "Grow the ecosystem with clear extension support and measurable adoption.",
});
- expect(draftSpy).toHaveBeenCalledWith("Grow plugin ecosystem", "/test/project", undefined);
+ expect(draftSpy).toHaveBeenCalledWith("Grow plugin ecosystem", "/test/project", undefined, store);
});
it("returns 400 when title is missing or empty", async () => {
@@ -2062,6 +2062,7 @@ describe("POST /api/ai/draft-goal-description", () => {
it("returns 429 when draft requests are rate limited", async () => {
const app = buildApp();
+ vi.spyOn(aiRefineModule, "draftGoalDescription").mockResolvedValue("Drafted goal description.");
for (let i = 0; i < 10; i++) {
const res = await REQUEST(
@@ -2927,6 +2928,7 @@ describe("Agent stale task-link sanitization", () => {
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
expect(testAgent).toBeDefined();
expect(testAgent).not.toHaveProperty("taskId");
+ expect(testAgent).not.toHaveProperty("taskColumn");
});
it("GET /api/agents omits taskId when linked task is archived", async () => {
@@ -2953,6 +2955,7 @@ describe("Agent stale task-link sanitization", () => {
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
expect(testAgent).toBeDefined();
expect(testAgent).not.toHaveProperty("taskId");
+ expect(testAgent).not.toHaveProperty("taskColumn");
});
it("GET /api/agents preserves taskId for non-terminal linked tasks", async () => {
@@ -2979,6 +2982,33 @@ describe("Agent stale task-link sanitization", () => {
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
expect(testAgent).toBeDefined();
expect(testAgent.taskId).toBe(activeTaskId);
+ expect(testAgent.taskColumn).toBe("in-progress");
+ });
+
+ it("GET /api/agents returns taskColumn for parked triage linked tasks", async () => {
+ const triageTaskId = "FN-TRIAGE";
+ const store = createMockStore({
+ getFusionDir: vi.fn().mockReturnValue(fusionDir),
+ getTaskColumns: vi.fn().mockResolvedValue(new Map([[triageTaskId, "triage"]])),
+ } as any);
+
+ const app = express();
+ app.use(express.json());
+ app.use("/api", createApiRoutes(store));
+
+ const { AgentStore } = await import("@fusion/core");
+ const agentStore = new AgentStore({ rootDir: fusionDir });
+ await agentStore.init();
+ await agentStore.assignTask(agentId, triageTaskId);
+
+ const res = await GET(app, "/api/agents");
+
+ expect(res.status).toBe(200);
+ const agents = Array.isArray(res.body) ? res.body : [res.body];
+ const testAgent = agents.find((a: { id: string }) => a.id === agentId);
+ expect(testAgent).toBeDefined();
+ expect(testAgent.taskId).toBe(triageTaskId);
+ expect(testAgent.taskColumn).toBe("triage");
});
it("GET /api/agents/:id omits taskId when linked task is done", async () => {
@@ -3004,6 +3034,7 @@ describe("Agent stale task-link sanitization", () => {
expect(res.body).toBeDefined();
expect(res.body.id).toBe(agentId);
expect(res.body).not.toHaveProperty("taskId");
+ expect(res.body).not.toHaveProperty("taskColumn");
});
it("GET /api/agents/:id omits taskId when linked task is archived", async () => {
@@ -3029,6 +3060,7 @@ describe("Agent stale task-link sanitization", () => {
expect(res.body).toBeDefined();
expect(res.body.id).toBe(agentId);
expect(res.body).not.toHaveProperty("taskId");
+ expect(res.body).not.toHaveProperty("taskColumn");
});
it("GET /api/agents/:id preserves taskId for in-review linked tasks", async () => {
@@ -3054,6 +3086,32 @@ describe("Agent stale task-link sanitization", () => {
expect(res.body).toBeDefined();
expect(res.body.id).toBe(agentId);
expect(res.body.taskId).toBe(inReviewTaskId);
+ expect(res.body.taskColumn).toBe("in-review");
+ });
+
+ it("GET /api/agents/:id returns taskColumn for in-progress linked tasks", async () => {
+ const inProgressTaskId = "FN-IN-PROGRESS";
+ const store = createMockStore({
+ getFusionDir: vi.fn().mockReturnValue(fusionDir),
+ getTaskColumns: vi.fn().mockResolvedValue(new Map([[inProgressTaskId, "in-progress"]])),
+ } as any);
+
+ const app = express();
+ app.use(express.json());
+ app.use("/api", createApiRoutes(store));
+
+ const { AgentStore } = await import("@fusion/core");
+ const agentStore = new AgentStore({ rootDir: fusionDir });
+ await agentStore.init();
+ await agentStore.assignTask(agentId, inProgressTaskId);
+
+ const res = await GET(app, `/api/agents/${agentId}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body).toBeDefined();
+ expect(res.body.id).toBe(agentId);
+ expect(res.body.taskId).toBe(inProgressTaskId);
+ expect(res.body.taskColumn).toBe("in-progress");
});
it("GET /api/agents/stats excludes terminal task links from assignedTaskCount", async () => {
@@ -3195,7 +3253,33 @@ describe("Agent stale task-link sanitization", () => {
expect(res.body.todoTaskCount).toBe(2);
});
- it("GET /api/agents handles task lookup failure gracefully", async () => {
+ it("GET /api/agents marks missing linked tasks as unresolved", async () => {
+ const taskId = "FN-MISSING";
+ const store = createMockStore({
+ getFusionDir: vi.fn().mockReturnValue(fusionDir),
+ getTaskColumns: vi.fn().mockResolvedValue(new Map()),
+ } as any);
+
+ const app = express();
+ app.use(express.json());
+ app.use("/api", createApiRoutes(store));
+
+ const { AgentStore } = await import("@fusion/core");
+ const agentStore = new AgentStore({ rootDir: fusionDir });
+ await agentStore.init();
+ await agentStore.assignTask(agentId, taskId);
+
+ const res = await GET(app, "/api/agents");
+
+ expect(res.status).toBe(200);
+ const agents = Array.isArray(res.body) ? res.body : [res.body];
+ const testAgent = agents.find((a: { id: string }) => a.id === agentId);
+ expect(testAgent).toBeDefined();
+ expect(testAgent.taskId).toBe(taskId);
+ expect(testAgent.taskColumn).toBe("unresolved");
+ });
+
+ it("GET /api/agents marks linked task lookup failures as unresolved", async () => {
const taskId = "FN-LOOKUP-FAIL";
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
@@ -3212,15 +3296,15 @@ describe("Agent stale task-link sanitization", () => {
await agentStore.init();
await agentStore.assignTask(agentId, taskId);
- // Should not throw, taskId should be preserved on lookup failure
+ // Should not throw; unresolved lookup state should be explicit on the response.
const res = await GET(app, "/api/agents");
expect(res.status).toBe(200);
const agents = Array.isArray(res.body) ? res.body : [res.body];
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
expect(testAgent).toBeDefined();
- // On lookup failure, taskId should be preserved (treated as non-terminal)
expect(testAgent.taskId).toBe(taskId);
+ expect(testAgent.taskColumn).toBe("unresolved");
});
});
diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts
index 6e50a6eb66..0c80162935 100644
--- a/packages/dashboard/src/routes.ts
+++ b/packages/dashboard/src/routes.ts
@@ -3198,6 +3198,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
* as "working on" in agent UI surfaces to avoid stale activity indicators.
*/
const TERMINAL_TASK_STATUSES = new Set(["done", "archived"]);
+ const UNRESOLVED_AGENT_TASK_COLUMN = "unresolved";
/**
* Check if a task status is terminal (done or archived).
@@ -3235,10 +3236,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const taskStatus = taskStatusMap.get(agent.taskId);
if (isTerminalTaskStatus(taskStatus)) {
// Omit taskId for terminal tasks — use spread to create shallow copy without taskId
- const { taskId: _omitted, ...sanitized } = agent;
+ const { taskId: _omitted, taskColumn: _taskColumnOmitted, ...sanitized } = agent;
return sanitized as import("@fusion/core").Agent;
}
- return agent;
+
+ /*
+ * FNXC:AgentTaskStateDrift 2026-06-27-16:20:
+ * Dashboard agent surfaces show the linked task column so coordinators can tell legitimate triage/queued or active linkage apart from execution drift, matching the FN-7138 text-surface invariant.
+ *
+ * FNXC:AgentTaskStateDrift 2026-06-27-17:08:
+ * Missing/deleted linked tasks and lookup failures must be explicit too; otherwise a stale task link is indistinguishable from an un-enriched dashboard response.
+ */
+ return { ...agent, taskColumn: taskStatus ?? UNRESOLVED_AGENT_TASK_COLUMN };
});
}