diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css
index 1443982d39..e2d664f432 100644
--- a/packages/dashboard/app/components/TaskCard.css
+++ b/packages/dashboard/app/components/TaskCard.css
@@ -397,6 +397,46 @@ not a restyle of the ordinary manual-approval badge.
border-color: color-mix(in srgb, var(--color-warning) 45%, transparent);
}
+/*
+FNXC:PlannerOversight 2026-07-05-00:00:
+FN-7592 replaces the overseer badge's uppercase text label with a small `Eye` icon so it
+reads as a compact glyph rather than a wide pill. Size the badge to the icon (no min-width,
+tight padding) and color it per `PlannerOverseerState` via the `data-planner-overseer-state`
+attribute so operators can distinguish watching/steering/recovering/awaiting-confirmation at
+a glance without reading text. Colors reuse existing semantic tokens: neutral/info for the
+passive "watching" state, warning for the more active "steering"/"recovering" states, and the
+triage token for "awaiting-confirmation" (a human-decision hold), matching the hue conventions
+used elsewhere in this file (e.g. .card-oversight-badge--*, .card-status-badge--triage).
+*/
+.card-planner-overseer-state {
+ padding: calc(var(--space-xs) / 2);
+ line-height: 0;
+}
+
+.card-planner-overseer-state svg {
+ width: 12px;
+ height: 12px;
+}
+
+.card-planner-overseer-state[data-planner-overseer-state="watching"] {
+ background: color-mix(in srgb, var(--color-info) 15%, transparent);
+ color: var(--color-info);
+ border-color: color-mix(in srgb, var(--color-info) 40%, transparent);
+}
+
+.card-planner-overseer-state[data-planner-overseer-state="steering"],
+.card-planner-overseer-state[data-planner-overseer-state="recovering"] {
+ background: color-mix(in srgb, var(--color-warning) 18%, transparent);
+ color: var(--color-warning);
+ border-color: color-mix(in srgb, var(--color-warning) 45%, transparent);
+}
+
+.card-planner-overseer-state[data-planner-overseer-state="awaiting-confirmation"] {
+ background: color-mix(in srgb, var(--triage) 18%, transparent);
+ color: var(--triage);
+ border-color: color-mix(in srgb, var(--triage) 45%, transparent);
+}
+
.card.awaiting-input {
border-left: 3px solid var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 6%, transparent);
diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx
index 2bcb016f3d..e312111e4e 100644
--- a/packages/dashboard/app/components/TaskCard.tsx
+++ b/packages/dashboard/app/components/TaskCard.tsx
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { memo, useCallback, useState, useRef, useEffect, useLayoutEffect, useMemo, type CSSProperties, type ReactElement } from "react";
import { createPortal } from "react-dom";
-import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest, AlertTriangle, ArrowUpRight } from "lucide-react";
+import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest, AlertTriangle, ArrowUpRight, Eye } from "lucide-react";
import type { Task, TaskDetail, Column, ColumnId, PrInfo, IssueInfo, TaskPriority, GithubIssueAction, MergeResult, PlannerOversightLevel } from "@fusion/core";
import {
DEFAULT_PLANNER_OVERSIGHT_LEVEL,
@@ -2889,15 +2889,25 @@ function TaskCardComponent({
board payload) plus a repaint-correct memo comparator; FN-7516 owns the styled
badge/design and surface-by-surface rendering. This is a minimal, type-safe,
guarded read only — nothing renders for an absent field or the "idle" state.
+
+ FNXC:PlannerOversight 2026-07-05-00:00:
+ FN-7592 replaces the uppercase text label with a small state-colored `Eye` icon so
+ the badge reads as a compact glyph. The readable label and composed tooltip stay
+ available for accessibility: `aria-label` carries the state name (screen readers)
+ and `title` keeps the existing tooltip (hover). Per-state color comes from the
+ `data-planner-overseer-state` attribute in TaskCard.css — do not fork the label
+ logic here; `plannerOverseerStateLabel`/`plannerOverseerBadgeTooltip` remain the
+ single source of truth.
*/}
{task.plannerOverseerState && task.plannerOverseerState.state !== "idle" && (
- {plannerOverseerStateLabel(task.plannerOverseerState.state, t)}
+
)}
{showStalledReview && stalledReview && (
diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
index 0f48411f2d..9cd637d2ab 100644
--- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
@@ -26,7 +26,9 @@ vi.mock("lucide-react", () => ({
Zap: () => ,
AlertTriangle: () => null,
ArrowUpRight: () => null,
- Eye: () => null,
+ // FN-7592: the overseer badge now renders an icon child instead of a text label,
+ // so tests must see a real SVG (like Zap) rather than a no-op render.
+ Eye: () => ,
}));
vi.mock("../ProviderIcon", () => ({
@@ -317,9 +319,13 @@ describe("TaskCard", () => {
render();
+ // FN-7592: the badge is now an icon-only glyph. The readable label moved from
+ // textContent to aria-label; the composed tooltip is unchanged on title.
const badge = screen.getByTestId("planner-overseer-state-badge");
- expect(badge.textContent).not.toBe("awaiting-confirmation");
- expect(badge.textContent).toBe("Awaiting confirmation");
+ expect(badge.querySelector("svg")).toBeInTheDocument();
+ expect(badge.getAttribute("aria-label")).not.toBe("awaiting-confirmation");
+ expect(badge.getAttribute("aria-label")).toBe("Awaiting confirmation");
+ expect(badge.getAttribute("data-planner-overseer-state")).toBe("awaiting-confirmation");
const title = badge.getAttribute("title") ?? "";
expect(title).not.toBe("Planner overseer: awaiting-confirmation");
@@ -343,8 +349,12 @@ describe("TaskCard", () => {
},
});
const { unmount } = render();
+ // FN-7592: icon-only badge — assert the accessible name via aria-label and the
+ // per-state color hook via data-planner-overseer-state, not raw text content.
let badge = screen.getByTestId("planner-overseer-state-badge");
- expect(badge.textContent).toBe("Overseer watching");
+ expect(badge.querySelector("svg")).toBeInTheDocument();
+ expect(badge.getAttribute("aria-label")).toBe("Overseer watching");
+ expect(badge.getAttribute("data-planner-overseer-state")).toBe("watching");
expect(badge.getAttribute("title")).not.toMatch(/undefined/);
unmount();
@@ -361,7 +371,12 @@ describe("TaskCard", () => {
});
render();
badge = screen.getByTestId("planner-overseer-state-badge");
- expect(badge.textContent).toBe("Overseer recovering");
+ expect(badge.querySelector("svg")).toBeInTheDocument();
+ expect(badge.getAttribute("aria-label")).toBe("Overseer recovering");
+ // Distinct states expose distinct data-planner-overseer-state values, which is the
+ // hook TaskCard.css keys per-state color off of (jsdom cannot compute color-mix()).
+ expect(badge.getAttribute("data-planner-overseer-state")).toBe("recovering");
+ expect(badge.getAttribute("data-planner-overseer-state")).not.toBe("watching");
const title = badge.getAttribute("title") ?? "";
expect(title).not.toMatch(/undefined/);
expect(title.length).toBeGreaterThan(0);