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:
@@ -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);
|
||||
|
||||
@@ -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 } : {})}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock("lucide-react", () => ({
|
||||
Folder: () => null,
|
||||
GitPullRequest: () => null,
|
||||
CircleDot: () => null,
|
||||
Target: () => null,
|
||||
}));
|
||||
|
||||
// Mock the api module
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user