feat(FN-970): add mission badge to TaskCard with navigation to MissionManager

- Add mission badge UI to TaskCard showing linked mission name with colored indicator
- Wire up onOpenMission prop through App → Board → Column → WorktreeGroup → TaskCard
- Add CSS styles for mission badge with hover and click states
- Update MissionManager with openMission callback support for navigation from cards
- Add comprehensive tests for mission badge rendering, click behavior, and edge cases
This commit is contained in:
gsxdsm
2026-04-05 12:03:50 -07:00
parent 6e4ecac40b
commit d01e31c3b4
9 changed files with 165 additions and 6 deletions

View File

@@ -97,6 +97,7 @@ function AppInner() {
const [planningResumeSessionId, setPlanningResumeSessionId] = useState<string | undefined>(undefined);
const [subtaskResumeSessionId, setSubtaskResumeSessionId] = useState<string | undefined>(undefined);
const [missionResumeSessionId, setMissionResumeSessionId] = useState<string | undefined>(undefined);
const [missionTargetId, setMissionTargetId] = useState<string | undefined>(undefined);
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
@@ -532,6 +533,12 @@ function AppInner() {
const handleOpenActivityLog = useCallback(() => setActivityLogOpen(true), []);
const handleCloseActivityLog = useCallback(() => setActivityLogOpen(false), []);
// Mission link handler from TaskCard
const handleOpenMission = useCallback((missionId: string) => {
setMissionTargetId(missionId);
setMissionsOpen(true);
}, []);
// Git Manager handlers
const handleOpenGitManager = useCallback(() => setGitManagerOpen(true), []);
const handleCloseGitManager = useCallback(() => setGitManagerOpen(false), []);
@@ -609,6 +616,7 @@ function AppInner() {
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={handleOpenMission}
/>
);
}
@@ -822,10 +830,11 @@ function AppInner() {
/>
<MissionManager
isOpen={missionsOpen}
onClose={() => { setMissionsOpen(false); setMissionResumeSessionId(undefined); }}
onClose={() => { setMissionsOpen(false); setMissionResumeSessionId(undefined); setMissionTargetId(undefined); }}
addToast={addToast}
projectId={currentProject?.id}
resumeSessionId={missionResumeSessionId}
targetMissionId={missionTargetId}
availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))}
onSelectTask={(taskId) => {
const task = tasks.find((t) => t.id === taskId);

View File

@@ -42,6 +42,8 @@ interface BoardProps {
onToggleModelFavorite?: (modelId: string) => void;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
/** Called when user clicks a mission badge on a task card */
onOpenMission?: (missionId: string) => void;
}
function sortTasksForColumn(tasks: Task[]): Task[] {
@@ -60,7 +62,7 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
return previous.every((task, index) => task === next[index]);
}
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs }: BoardProps) {
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
const { fetchBatch } = useBatchBadgeFetch(projectId);
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -178,6 +180,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
onToggleModelFavorite={onToggleModelFavorite}
isSearchActive={isSearchActive}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
{...(col === "done" ? { onArchiveAllDone } : {})}

View File

@@ -55,9 +55,11 @@ interface ColumnProps {
isSearchActive?: boolean;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
/** Called when user clicks a mission badge on a task card */
onOpenMission?: (missionId: string) => void;
}
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs }: ColumnProps) {
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission }: ColumnProps) {
const [dragOver, setDragOver] = useState(false);
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
const countFlashing = useFlashOnIncrease(tasks.length);
@@ -228,6 +230,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
onUpdateTask={onUpdateTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
/>
))
)
@@ -248,6 +251,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
onUnarchiveTask={onUnarchiveTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
/>
))}
{shouldPaginate && hiddenTaskCount > 0 && (

View File

@@ -70,6 +70,8 @@ interface MissionManagerProps {
onSelectTask?: (taskId: string) => void;
availableTasks?: Array<{ id: string; title?: string }>;
resumeSessionId?: string;
/** Pre-select and load this mission when the modal opens */
targetMissionId?: string;
}
// Status badge colors — use CSS custom-property-compatible tokens
@@ -156,7 +158,7 @@ const EMPTY_FEATURE_FORM: FeatureFormData = {
status: "defined",
};
export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId }: MissionManagerProps) {
export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId }: MissionManagerProps) {
const [missions, setMissions] = useState<Mission[]>([]);
const [selectedMission, setSelectedMission] = useState<MissionWithHierarchy | null>(null);
const [loading, setLoading] = useState(true);
@@ -241,6 +243,22 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
}
}, [isOpen, loadMissions]);
// Auto-load target mission when specified
const targetLoadedRef = useRef<string | null>(null);
useEffect(() => {
if (isOpen && targetMissionId && targetLoadedRef.current !== targetMissionId && missions.length > 0) {
targetLoadedRef.current = targetMissionId;
loadMissionDetail(targetMissionId);
}
}, [isOpen, targetMissionId, missions, loadMissionDetail]);
// Reset target tracking when modal closes
useEffect(() => {
if (!isOpen) {
targetLoadedRef.current = null;
}
}, [isOpen]);
// Mission handlers
const handleCreateMission = useCallback(() => {
setIsCreatingMission(true);

View File

@@ -13,6 +13,7 @@ vi.mock("lucide-react", () => ({
Folder: () => null,
GitPullRequest: () => null,
CircleDot: () => null,
Target: () => null,
}));
// Mock the api module

View File

@@ -1,5 +1,5 @@
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
import { Link, Clock, Layers, Pencil, ChevronDown, Folder } from "lucide-react";
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target } from "lucide-react";
import type { Task, TaskDetail, Column, PrInfo, IssueInfo } from "@fusion/core";
import { fetchTaskDetail, uploadAttachment } from "../api";
import { GitHubBadge } from "./GitHubBadge";
@@ -49,6 +49,8 @@ interface TaskCardProps {
onOpenDetailWithTab?: (task: TaskDetail, initialTab: "changes") => void;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
/** Called when user clicks the mission badge on a task card. */
onOpenMission?: (missionId: string) => void;
}
function areTaskBadgeInfosEqual(
@@ -93,6 +95,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.onArchiveTask === next.onArchiveTask &&
previous.onUnarchiveTask === next.onUnarchiveTask &&
previous.onOpenDetailWithTab === next.onOpenDetailWithTab &&
previous.onOpenMission === next.onOpenMission &&
previousTask.id === nextTask.id &&
previousTask.title === nextTask.title &&
previousTask.description === nextTask.description &&
@@ -114,6 +117,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previousTask.validatorModelProvider === nextTask.validatorModelProvider &&
previousTask.validatorModelId === nextTask.validatorModelId &&
previousTask.reviewLevel === nextTask.reviewLevel &&
previousTask.missionId === nextTask.missionId &&
previousTask.mergeRetries === nextTask.mergeRetries &&
JSON.stringify(previousTask.attachments ?? []) === JSON.stringify(nextTask.attachments ?? []) &&
JSON.stringify(previousTask.comments ?? []) === JSON.stringify(nextTask.comments ?? []) &&
@@ -136,6 +140,7 @@ function TaskCardComponent({
onUnarchiveTask,
onOpenDetailWithTab,
taskStuckTimeoutMs,
onOpenMission,
}: TaskCardProps) {
const [dragging, setDragging] = useState(false);
const [fileDragOver, setFileDragOver] = useState(false);
@@ -516,6 +521,13 @@ function TaskCardComponent({
setShowSteps((current) => !current);
}, []);
const handleMissionClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (task.missionId && onOpenMission) {
onOpenMission(task.missionId);
}
}, [task.missionId, onOpenMission]);
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isStuck ? " stuck" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`;
if (isEditing) {
@@ -600,6 +612,18 @@ function TaskCardComponent({
issueInfo={liveIssueInfo}
/>
)}
{task.missionId && (
<span
className="card-mission-badge"
onClick={handleMissionClick}
title={`Mission: ${task.missionId}`}
role={onOpenMission ? "button" : undefined}
tabIndex={onOpenMission ? 0 : undefined}
>
<Target size={11} />
{task.missionId}
</span>
)}
<div className="card-header-actions">
{canEdit && (
<button

View File

@@ -19,6 +19,8 @@ interface WorktreeGroupProps {
onOpenDetailWithTab?: (task: TaskDetail, initialTab: "changes") => void;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
/** Called when user clicks a mission badge on a task card */
onOpenMission?: (missionId: string) => void;
}
function WorktreeGroupComponent({
@@ -32,6 +34,7 @@ function WorktreeGroupComponent({
onUpdateTask,
onOpenDetailWithTab,
taskStuckTimeoutMs,
onOpenMission,
}: WorktreeGroupProps) {
return (
<div className="worktree-group">
@@ -42,7 +45,7 @@ function WorktreeGroupComponent({
<span className="worktree-label">{label}</span>
</div>
{activeTasks.map((task) => (
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} />
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} />
))}
{queuedTasks.map((task) => (
<TaskCard
@@ -56,6 +59,7 @@ function WorktreeGroupComponent({
onUpdateTask={onUpdateTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
/>
))}
</div>

View File

@@ -43,6 +43,7 @@ vi.mock("lucide-react", () => ({
Maximize2: ({ size }: { size?: number }) => <span data-testid="maximize-icon"></span>,
GitPullRequest: ({ size }: { size?: number }) => <span data-testid="git-pr-icon">🔀</span>,
CircleDot: ({ size }: { size?: number }) => <span data-testid="circle-dot-icon"></span>,
Target: ({ size }: { size?: number }) => <span data-testid="target-icon">🎯</span>,
}));
beforeEach(() => {
@@ -3226,3 +3227,77 @@ describe("TaskCard files-changed in done column", () => {
expect(screen.getByText("3 files changed")).toBeInTheDocument();
});
});
describe("TaskCard mission badge", () => {
const createTask = (overrides: Partial<Task> = {}): Task => ({
id: "FN-001",
description: "Test task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
columnMovedAt: "2026-01-01T00:00:00Z",
...overrides,
} as Task);
it("renders mission badge when task has missionId", () => {
const task = createTask({ missionId: "MSN-001" });
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} />);
const badge = screen.getByTitle("Mission: MSN-001");
expect(badge).toBeInTheDocument();
expect(badge).toHaveClass("card-mission-badge");
expect(badge).toHaveTextContent("MSN-001");
});
it("does not render mission badge when task has no missionId", () => {
const task = createTask();
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} />);
expect(screen.queryByTitle(/Mission:/)).not.toBeInTheDocument();
expect(screen.queryByTestId("target-icon")).not.toBeInTheDocument();
});
it("calls onOpenMission when mission badge is clicked", async () => {
const onOpenMission = vi.fn();
const task = createTask({ missionId: "MSN-042" });
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={vi.fn()}
onOpenMission={onOpenMission}
/>
);
const badge = screen.getByTitle("Mission: MSN-042");
await userEvent.click(badge);
expect(onOpenMission).toHaveBeenCalledOnce();
expect(onOpenMission).toHaveBeenCalledWith("MSN-042");
});
it("stops propagation when mission badge is clicked", async () => {
const onOpenDetail = vi.fn();
const onOpenMission = vi.fn();
const task = createTask({ missionId: "MSN-001" });
render(
<TaskCard
task={task}
onOpenDetail={onOpenDetail}
addToast={vi.fn()}
onOpenMission={onOpenMission}
/>
);
const badge = screen.getByTitle("Mission: MSN-001");
await userEvent.click(badge);
// onOpenDetail should NOT be called since click is stopped
expect(onOpenDetail).not.toHaveBeenCalled();
expect(onOpenMission).toHaveBeenCalledWith("MSN-001");
});
});

View File

@@ -1201,6 +1201,27 @@ body {
box-shadow: var(--shadow-md);
}
/* Mission badge: clickable link to mission from task card */
.card-mission-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10px;
font-weight: 600;
font-family: var(--font-mono);
color: #a78bfa;
background: rgba(167, 139, 250, 0.12);
padding: 2px 6px;
border-radius: var(--radius-pill);
cursor: pointer;
transition: color var(--transition-fast), background var(--transition-fast);
}
.card-mission-badge:hover {
color: #c4b5fd;
background: rgba(167, 139, 250, 0.22);
}
.card-progress {
display: flex;
align-items: center;