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 [planningResumeSessionId, setPlanningResumeSessionId] = useState<string | undefined>(undefined);
|
||||||
const [subtaskResumeSessionId, setSubtaskResumeSessionId] = useState<string | undefined>(undefined);
|
const [subtaskResumeSessionId, setSubtaskResumeSessionId] = useState<string | undefined>(undefined);
|
||||||
const [missionResumeSessionId, setMissionResumeSessionId] = 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 [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
|
||||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
|
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
|
||||||
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
|
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
|
||||||
@@ -532,6 +533,12 @@ function AppInner() {
|
|||||||
const handleOpenActivityLog = useCallback(() => setActivityLogOpen(true), []);
|
const handleOpenActivityLog = useCallback(() => setActivityLogOpen(true), []);
|
||||||
const handleCloseActivityLog = useCallback(() => setActivityLogOpen(false), []);
|
const handleCloseActivityLog = useCallback(() => setActivityLogOpen(false), []);
|
||||||
|
|
||||||
|
// Mission link handler from TaskCard
|
||||||
|
const handleOpenMission = useCallback((missionId: string) => {
|
||||||
|
setMissionTargetId(missionId);
|
||||||
|
setMissionsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Git Manager handlers
|
// Git Manager handlers
|
||||||
const handleOpenGitManager = useCallback(() => setGitManagerOpen(true), []);
|
const handleOpenGitManager = useCallback(() => setGitManagerOpen(true), []);
|
||||||
const handleCloseGitManager = useCallback(() => setGitManagerOpen(false), []);
|
const handleCloseGitManager = useCallback(() => setGitManagerOpen(false), []);
|
||||||
@@ -609,6 +616,7 @@ function AppInner() {
|
|||||||
onToggleFavorite={handleToggleFavorite}
|
onToggleFavorite={handleToggleFavorite}
|
||||||
onToggleModelFavorite={handleToggleModelFavorite}
|
onToggleModelFavorite={handleToggleModelFavorite}
|
||||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||||
|
onOpenMission={handleOpenMission}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -822,10 +830,11 @@ function AppInner() {
|
|||||||
/>
|
/>
|
||||||
<MissionManager
|
<MissionManager
|
||||||
isOpen={missionsOpen}
|
isOpen={missionsOpen}
|
||||||
onClose={() => { setMissionsOpen(false); setMissionResumeSessionId(undefined); }}
|
onClose={() => { setMissionsOpen(false); setMissionResumeSessionId(undefined); setMissionTargetId(undefined); }}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
projectId={currentProject?.id}
|
projectId={currentProject?.id}
|
||||||
resumeSessionId={missionResumeSessionId}
|
resumeSessionId={missionResumeSessionId}
|
||||||
|
targetMissionId={missionTargetId}
|
||||||
availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))}
|
availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))}
|
||||||
onSelectTask={(taskId) => {
|
onSelectTask={(taskId) => {
|
||||||
const task = tasks.find((t) => t.id === taskId);
|
const task = tasks.find((t) => t.id === taskId);
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ interface BoardProps {
|
|||||||
onToggleModelFavorite?: (modelId: string) => void;
|
onToggleModelFavorite?: (modelId: string) => void;
|
||||||
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||||
taskStuckTimeoutMs?: number;
|
taskStuckTimeoutMs?: number;
|
||||||
|
/** Called when user clicks a mission badge on a task card */
|
||||||
|
onOpenMission?: (missionId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortTasksForColumn(tasks: Task[]): Task[] {
|
function sortTasksForColumn(tasks: Task[]): Task[] {
|
||||||
@@ -60,7 +62,7 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
|
|||||||
return previous.every((task, index) => task === next[index]);
|
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 [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||||
const { fetchBatch } = useBatchBadgeFetch(projectId);
|
const { fetchBatch } = useBatchBadgeFetch(projectId);
|
||||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -178,6 +180,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
|
|||||||
onToggleModelFavorite={onToggleModelFavorite}
|
onToggleModelFavorite={onToggleModelFavorite}
|
||||||
isSearchActive={isSearchActive}
|
isSearchActive={isSearchActive}
|
||||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||||
|
onOpenMission={onOpenMission}
|
||||||
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||||
{...(col === "done" ? { onArchiveAllDone } : {})}
|
{...(col === "done" ? { onArchiveAllDone } : {})}
|
||||||
|
|||||||
@@ -55,9 +55,11 @@ interface ColumnProps {
|
|||||||
isSearchActive?: boolean;
|
isSearchActive?: boolean;
|
||||||
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||||
taskStuckTimeoutMs?: number;
|
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 [dragOver, setDragOver] = useState(false);
|
||||||
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
||||||
const countFlashing = useFlashOnIncrease(tasks.length);
|
const countFlashing = useFlashOnIncrease(tasks.length);
|
||||||
@@ -228,6 +230,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
|||||||
onUpdateTask={onUpdateTask}
|
onUpdateTask={onUpdateTask}
|
||||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||||
|
onOpenMission={onOpenMission}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
@@ -248,6 +251,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
|||||||
onUnarchiveTask={onUnarchiveTask}
|
onUnarchiveTask={onUnarchiveTask}
|
||||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||||
|
onOpenMission={onOpenMission}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{shouldPaginate && hiddenTaskCount > 0 && (
|
{shouldPaginate && hiddenTaskCount > 0 && (
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ interface MissionManagerProps {
|
|||||||
onSelectTask?: (taskId: string) => void;
|
onSelectTask?: (taskId: string) => void;
|
||||||
availableTasks?: Array<{ id: string; title?: string }>;
|
availableTasks?: Array<{ id: string; title?: string }>;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
|
/** Pre-select and load this mission when the modal opens */
|
||||||
|
targetMissionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status badge colors — use CSS custom-property-compatible tokens
|
// Status badge colors — use CSS custom-property-compatible tokens
|
||||||
@@ -156,7 +158,7 @@ const EMPTY_FEATURE_FORM: FeatureFormData = {
|
|||||||
status: "defined",
|
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 [missions, setMissions] = useState<Mission[]>([]);
|
||||||
const [selectedMission, setSelectedMission] = useState<MissionWithHierarchy | null>(null);
|
const [selectedMission, setSelectedMission] = useState<MissionWithHierarchy | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -241,6 +243,22 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
|
|||||||
}
|
}
|
||||||
}, [isOpen, loadMissions]);
|
}, [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
|
// Mission handlers
|
||||||
const handleCreateMission = useCallback(() => {
|
const handleCreateMission = useCallback(() => {
|
||||||
setIsCreatingMission(true);
|
setIsCreatingMission(true);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ vi.mock("lucide-react", () => ({
|
|||||||
Folder: () => null,
|
Folder: () => null,
|
||||||
GitPullRequest: () => null,
|
GitPullRequest: () => null,
|
||||||
CircleDot: () => null,
|
CircleDot: () => null,
|
||||||
|
Target: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the api module
|
// Mock the api module
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
|
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 type { Task, TaskDetail, Column, PrInfo, IssueInfo } from "@fusion/core";
|
||||||
import { fetchTaskDetail, uploadAttachment } from "../api";
|
import { fetchTaskDetail, uploadAttachment } from "../api";
|
||||||
import { GitHubBadge } from "./GitHubBadge";
|
import { GitHubBadge } from "./GitHubBadge";
|
||||||
@@ -49,6 +49,8 @@ interface TaskCardProps {
|
|||||||
onOpenDetailWithTab?: (task: TaskDetail, initialTab: "changes") => void;
|
onOpenDetailWithTab?: (task: TaskDetail, initialTab: "changes") => void;
|
||||||
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||||
taskStuckTimeoutMs?: number;
|
taskStuckTimeoutMs?: number;
|
||||||
|
/** Called when user clicks the mission badge on a task card. */
|
||||||
|
onOpenMission?: (missionId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function areTaskBadgeInfosEqual(
|
function areTaskBadgeInfosEqual(
|
||||||
@@ -93,6 +95,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
|||||||
previous.onArchiveTask === next.onArchiveTask &&
|
previous.onArchiveTask === next.onArchiveTask &&
|
||||||
previous.onUnarchiveTask === next.onUnarchiveTask &&
|
previous.onUnarchiveTask === next.onUnarchiveTask &&
|
||||||
previous.onOpenDetailWithTab === next.onOpenDetailWithTab &&
|
previous.onOpenDetailWithTab === next.onOpenDetailWithTab &&
|
||||||
|
previous.onOpenMission === next.onOpenMission &&
|
||||||
previousTask.id === nextTask.id &&
|
previousTask.id === nextTask.id &&
|
||||||
previousTask.title === nextTask.title &&
|
previousTask.title === nextTask.title &&
|
||||||
previousTask.description === nextTask.description &&
|
previousTask.description === nextTask.description &&
|
||||||
@@ -114,6 +117,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
|||||||
previousTask.validatorModelProvider === nextTask.validatorModelProvider &&
|
previousTask.validatorModelProvider === nextTask.validatorModelProvider &&
|
||||||
previousTask.validatorModelId === nextTask.validatorModelId &&
|
previousTask.validatorModelId === nextTask.validatorModelId &&
|
||||||
previousTask.reviewLevel === nextTask.reviewLevel &&
|
previousTask.reviewLevel === nextTask.reviewLevel &&
|
||||||
|
previousTask.missionId === nextTask.missionId &&
|
||||||
previousTask.mergeRetries === nextTask.mergeRetries &&
|
previousTask.mergeRetries === nextTask.mergeRetries &&
|
||||||
JSON.stringify(previousTask.attachments ?? []) === JSON.stringify(nextTask.attachments ?? []) &&
|
JSON.stringify(previousTask.attachments ?? []) === JSON.stringify(nextTask.attachments ?? []) &&
|
||||||
JSON.stringify(previousTask.comments ?? []) === JSON.stringify(nextTask.comments ?? []) &&
|
JSON.stringify(previousTask.comments ?? []) === JSON.stringify(nextTask.comments ?? []) &&
|
||||||
@@ -136,6 +140,7 @@ function TaskCardComponent({
|
|||||||
onUnarchiveTask,
|
onUnarchiveTask,
|
||||||
onOpenDetailWithTab,
|
onOpenDetailWithTab,
|
||||||
taskStuckTimeoutMs,
|
taskStuckTimeoutMs,
|
||||||
|
onOpenMission,
|
||||||
}: TaskCardProps) {
|
}: TaskCardProps) {
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [fileDragOver, setFileDragOver] = useState(false);
|
const [fileDragOver, setFileDragOver] = useState(false);
|
||||||
@@ -516,6 +521,13 @@ function TaskCardComponent({
|
|||||||
setShowSteps((current) => !current);
|
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" : ""}`;
|
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) {
|
if (isEditing) {
|
||||||
@@ -600,6 +612,18 @@ function TaskCardComponent({
|
|||||||
issueInfo={liveIssueInfo}
|
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">
|
<div className="card-header-actions">
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ interface WorktreeGroupProps {
|
|||||||
onOpenDetailWithTab?: (task: TaskDetail, initialTab: "changes") => void;
|
onOpenDetailWithTab?: (task: TaskDetail, initialTab: "changes") => void;
|
||||||
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||||
taskStuckTimeoutMs?: number;
|
taskStuckTimeoutMs?: number;
|
||||||
|
/** Called when user clicks a mission badge on a task card */
|
||||||
|
onOpenMission?: (missionId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorktreeGroupComponent({
|
function WorktreeGroupComponent({
|
||||||
@@ -32,6 +34,7 @@ function WorktreeGroupComponent({
|
|||||||
onUpdateTask,
|
onUpdateTask,
|
||||||
onOpenDetailWithTab,
|
onOpenDetailWithTab,
|
||||||
taskStuckTimeoutMs,
|
taskStuckTimeoutMs,
|
||||||
|
onOpenMission,
|
||||||
}: WorktreeGroupProps) {
|
}: WorktreeGroupProps) {
|
||||||
return (
|
return (
|
||||||
<div className="worktree-group">
|
<div className="worktree-group">
|
||||||
@@ -42,7 +45,7 @@ function WorktreeGroupComponent({
|
|||||||
<span className="worktree-label">{label}</span>
|
<span className="worktree-label">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
{activeTasks.map((task) => (
|
{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) => (
|
{queuedTasks.map((task) => (
|
||||||
<TaskCard
|
<TaskCard
|
||||||
@@ -56,6 +59,7 @@ function WorktreeGroupComponent({
|
|||||||
onUpdateTask={onUpdateTask}
|
onUpdateTask={onUpdateTask}
|
||||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||||
|
onOpenMission={onOpenMission}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ vi.mock("lucide-react", () => ({
|
|||||||
Maximize2: ({ size }: { size?: number }) => <span data-testid="maximize-icon">⛶</span>,
|
Maximize2: ({ size }: { size?: number }) => <span data-testid="maximize-icon">⛶</span>,
|
||||||
GitPullRequest: ({ size }: { size?: number }) => <span data-testid="git-pr-icon">🔀</span>,
|
GitPullRequest: ({ size }: { size?: number }) => <span data-testid="git-pr-icon">🔀</span>,
|
||||||
CircleDot: ({ size }: { size?: number }) => <span data-testid="circle-dot-icon">⭕</span>,
|
CircleDot: ({ size }: { size?: number }) => <span data-testid="circle-dot-icon">⭕</span>,
|
||||||
|
Target: ({ size }: { size?: number }) => <span data-testid="target-icon">🎯</span>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -3226,3 +3227,77 @@ describe("TaskCard files-changed in done column", () => {
|
|||||||
expect(screen.getByText("3 files changed")).toBeInTheDocument();
|
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);
|
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 {
|
.card-progress {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user