feat(FN-3191): keep embedded task-link navigation in split pane
Fixes task link navigation in ListView so embedded links continue working when the split pane is active, including a new test case for FN-3191. Fusion-Task-Id: FN-3191
This commit is contained in:
@@ -880,6 +880,10 @@ function AppInner() {
|
||||
projectId={currentProject?.id}
|
||||
onMoveTask={moveTask}
|
||||
onRetryTask={retryTask}
|
||||
onDeleteTask={deleteTask}
|
||||
onMergeTask={mergeTask}
|
||||
onResetTask={resetTask}
|
||||
onDuplicateTask={duplicateTask}
|
||||
onOpenDetail={modalManager.openDetailTask}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
@@ -895,6 +899,7 @@ function AppInner() {
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
searchQuery={searchQuery}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
/>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -339,6 +339,12 @@
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.list-split-detail-empty {
|
||||
height: 100%;
|
||||
padding: var(--space-xl);
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
@@ -347,11 +353,16 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.list-split-detail p {
|
||||
.list-split-detail-empty p {
|
||||
margin-block: 0;
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.list-split-detail-content {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.list-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import "./ListView.css";
|
||||
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, TaskCreateInput } from "@fusion/core";
|
||||
import type { Task, TaskDetail, Column, TaskCreateInput, MergeResult } from "@fusion/core";
|
||||
import { COLUMN_LABELS, COLUMNS, getErrorMessage } from "@fusion/core";
|
||||
import { batchUpdateTaskModels, fetchNodes } from "../api";
|
||||
import { batchUpdateTaskModels, fetchNodes, fetchTaskDetail } from "../api";
|
||||
import { TaskDetailContent } from "./TaskDetailModal";
|
||||
import type { ModelInfo, NodeInfo } from "../api";
|
||||
import { QuickEntryBox } from "./QuickEntryBox";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
@@ -157,8 +158,12 @@ function clampSidebarWidth(width: number, containerWidth: number): number {
|
||||
|
||||
interface ListViewProps {
|
||||
tasks: Task[];
|
||||
onMoveTask: (id: string, column: Column, options?: { preserveProgress?: boolean }) => Promise<Task>;
|
||||
onMoveTask: (id: string, column: Column, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
|
||||
onRetryTask?: (id: string) => Promise<Task>;
|
||||
onDeleteTask: (id: string, options?: { removeDependencyReferences?: boolean }) => Promise<Task>;
|
||||
onMergeTask: (id: string) => Promise<MergeResult>;
|
||||
onResetTask?: (id: string) => Promise<Task>;
|
||||
onDuplicateTask?: (id: string) => Promise<Task>;
|
||||
onOpenDetail: (task: Task | TaskDetail) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
globalPaused?: boolean;
|
||||
@@ -192,6 +197,7 @@ interface ListViewProps {
|
||||
searchQuery?: string;
|
||||
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
|
||||
lastFetchTimeMs?: number;
|
||||
prAuthAvailable?: boolean;
|
||||
}
|
||||
|
||||
function shouldShowTaskProgress(task: Task): boolean {
|
||||
@@ -214,6 +220,11 @@ function getTaskProgress(task: Task): { label: string; percent: number; hasProgr
|
||||
export function ListView({
|
||||
tasks,
|
||||
onMoveTask,
|
||||
onRetryTask,
|
||||
onDeleteTask,
|
||||
onMergeTask,
|
||||
onResetTask,
|
||||
onDuplicateTask,
|
||||
onOpenDetail,
|
||||
addToast,
|
||||
globalPaused,
|
||||
@@ -232,6 +243,7 @@ export function ListView({
|
||||
taskStuckTimeoutMs,
|
||||
searchQuery = "",
|
||||
lastFetchTimeMs,
|
||||
prAuthAvailable,
|
||||
}: ListViewProps) {
|
||||
const [sortField, setSortField] = useState<SortField>("id");
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
|
||||
@@ -282,6 +294,7 @@ export function ListView({
|
||||
const [bulkEditEnabled, setBulkEditEnabled] = useState(false);
|
||||
const [selectedTaskIds, setSelectedTaskIds] = useState<Set<string>>(() => readSelectedTaskIds(projectId));
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(() => readSelectedTaskId(projectId));
|
||||
const [selectedTaskSnapshot, setSelectedTaskSnapshot] = useState<Task | TaskDetail | null>(null);
|
||||
const [sidebarWidth, setSidebarWidth] = useState<number>(() => readSidebarWidth(projectId));
|
||||
const splitLayoutRef = useRef<HTMLDivElement>(null);
|
||||
const splitSidebarRef = useRef<HTMLDivElement>(null);
|
||||
@@ -291,9 +304,13 @@ export function ListView({
|
||||
setHideDoneTasks(readHideDoneTasks(projectId));
|
||||
setCollapsedSections(readCollapsedSections(projectId));
|
||||
setSelectedTaskIds(readSelectedTaskIds(projectId));
|
||||
setSelectedTaskId(readSelectedTaskId(projectId));
|
||||
const persistedSelection = readSelectedTaskId(projectId);
|
||||
setSelectedTaskId(persistedSelection);
|
||||
setSelectedTaskSnapshot(
|
||||
persistedSelection ? tasks.find((task) => task.id === persistedSelection) ?? null : null,
|
||||
);
|
||||
setSidebarWidth(readSidebarWidth(projectId));
|
||||
}, [projectId]);
|
||||
}, [projectId, tasks]);
|
||||
|
||||
// Persist selection to localStorage
|
||||
useEffect(() => {
|
||||
@@ -313,10 +330,20 @@ export function ListView({
|
||||
}, [projectId, selectedTaskId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTaskId) return;
|
||||
if (!tasks.some((task) => task.id === selectedTaskId)) {
|
||||
setSelectedTaskId(null);
|
||||
if (!selectedTaskId) {
|
||||
setSelectedTaskSnapshot(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const liveTask = tasks.find((task) => task.id === selectedTaskId);
|
||||
if (!liveTask) return;
|
||||
|
||||
setSelectedTaskSnapshot((previous) => {
|
||||
if (!previous || previous.id !== selectedTaskId) {
|
||||
return liveTask;
|
||||
}
|
||||
return { ...previous, ...liveTask };
|
||||
});
|
||||
}, [selectedTaskId, tasks]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -733,10 +760,33 @@ export function ListView({
|
||||
}
|
||||
|
||||
setSelectedTaskId(task.id);
|
||||
setSelectedTaskSnapshot(task);
|
||||
},
|
||||
[isMobile, onOpenDetail]
|
||||
);
|
||||
|
||||
const handleEmbeddedOpenDetail = useCallback((nextTask: Task | TaskDetail) => {
|
||||
setSelectedTaskId(nextTask.id);
|
||||
setSelectedTaskSnapshot(nextTask);
|
||||
|
||||
if ("prompt" in nextTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchTaskDetail(nextTask.id, projectId)
|
||||
.then((detail) => {
|
||||
setSelectedTaskSnapshot((previous) => {
|
||||
if (!previous || previous.id !== detail.id) {
|
||||
return previous;
|
||||
}
|
||||
return { ...previous, ...detail };
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep optimistic inline selection when detail fetch fails.
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent, task: Task) => {
|
||||
if (task.paused) {
|
||||
@@ -1456,10 +1506,34 @@ export function ListView({
|
||||
aria-valuenow={Math.round(sidebarWidth)}
|
||||
/>
|
||||
<div className="list-split-detail" data-testid="list-split-detail">
|
||||
{!selectedTaskId ? (
|
||||
<p>Select a task to view details</p>
|
||||
{!selectedTaskSnapshot ? (
|
||||
<div className="list-split-detail-empty">
|
||||
<p>Select a task to view details</p>
|
||||
</div>
|
||||
) : (
|
||||
<p>Task selected. Detail view coming soon.</p>
|
||||
<div className="list-split-detail-content" data-testid="list-split-detail-content">
|
||||
<TaskDetailContent
|
||||
task={selectedTaskSnapshot}
|
||||
projectId={projectId}
|
||||
tasks={tasks}
|
||||
embedded
|
||||
onOpenDetail={handleEmbeddedOpenDetail}
|
||||
onMoveTask={onMoveTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
onMergeTask={onMergeTask}
|
||||
onRetryTask={onRetryTask}
|
||||
onResetTask={onResetTask}
|
||||
onDuplicateTask={onDuplicateTask}
|
||||
onTaskUpdated={(updatedTask) => {
|
||||
setSelectedTaskSnapshot((previous) => {
|
||||
if (!previous || previous.id !== updatedTask.id) return previous;
|
||||
return { ...previous, ...updatedTask };
|
||||
});
|
||||
}}
|
||||
addToast={addToast}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
/* === Detail Modal === */
|
||||
.task-detail-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.modal.task-detail-modal {
|
||||
width: min(95vw, 800px);
|
||||
max-width: 95vw;
|
||||
@@ -600,7 +607,7 @@
|
||||
}
|
||||
|
||||
.detail-refine-overlay {
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -610,11 +617,21 @@
|
||||
}
|
||||
|
||||
.detail-refine-modal {
|
||||
max-width: 500px;
|
||||
max-width: min(90vw, calc(var(--space-2xl) * 16));
|
||||
width: 90%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-detail-content--embedded {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.task-detail-content--embedded .detail-actions-menu,
|
||||
.task-detail-content--embedded .detail-move-menu {
|
||||
top: calc(100% + var(--space-xs));
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.detail-refine-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ function formatBytes(bytes: number): string {
|
||||
|
||||
type TabId = "definition" | "logs" | "changes" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | `plugin-${string}`;
|
||||
|
||||
interface TaskDetailModalProps {
|
||||
export interface TaskDetailModalProps {
|
||||
task: Task | TaskDetail;
|
||||
projectId?: string;
|
||||
tasks?: Task[];
|
||||
@@ -169,6 +169,11 @@ interface TaskDetailModalProps {
|
||||
initialTab?: TabId;
|
||||
}
|
||||
|
||||
export type TaskDetailContentProps = Omit<TaskDetailModalProps, "onClose"> & {
|
||||
embedded?: boolean;
|
||||
onRequestClose?: () => void;
|
||||
};
|
||||
|
||||
function extractDependencyDeleteConflict(err: unknown): { dependentIds: string[] } | null {
|
||||
if (!(err instanceof Error)) {
|
||||
return null;
|
||||
@@ -312,11 +317,10 @@ const DESCRIPTION_TRUNCATE_LENGTH = 200;
|
||||
|
||||
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
|
||||
|
||||
export function TaskDetailModal({
|
||||
export function TaskDetailContent({
|
||||
task,
|
||||
projectId,
|
||||
tasks = [],
|
||||
onClose,
|
||||
onOpenDetail,
|
||||
onMoveTask,
|
||||
onDeleteTask,
|
||||
@@ -328,7 +332,9 @@ export function TaskDetailModal({
|
||||
addToast,
|
||||
prAuthAvailable,
|
||||
initialTab = "definition",
|
||||
}: TaskDetailModalProps) {
|
||||
embedded = false,
|
||||
onRequestClose,
|
||||
}: TaskDetailContentProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabId>(initialTab);
|
||||
|
||||
// ── Async detail loading ──────────────────────────────────────────────────
|
||||
@@ -457,8 +463,6 @@ export function TaskDetailModal({
|
||||
const moveMenuRef = useRef<HTMLDivElement>(null);
|
||||
const moveButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const actionsMenuRef = useRef<HTMLDivElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
useModalResizePersist(modalRef, true, "fusion:task-detail-modal-size");
|
||||
|
||||
// Plugin UI slots for task-detail-tab
|
||||
const { getSlotsForId: getPluginSlots } = usePluginUiSlots(projectId);
|
||||
@@ -946,15 +950,18 @@ export function TaskDetailModal({
|
||||
activeTab === "logs" && logSubview === "agent-log",
|
||||
projectId,
|
||||
);
|
||||
const requestClose = useCallback(() => {
|
||||
onRequestClose?.();
|
||||
}, [onRequestClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (embedded) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !isEditing) onClose();
|
||||
if (e.key === "Escape" && !isEditing) requestClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [onClose, isEditing]);
|
||||
|
||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
||||
}, [embedded, requestClose, isEditing]);
|
||||
|
||||
const handleMove = useCallback(
|
||||
async (column: Column) => {
|
||||
@@ -988,13 +995,13 @@ export function TaskDetailModal({
|
||||
}
|
||||
|
||||
await onMoveTask(task.id, column, moveOptions);
|
||||
onClose();
|
||||
requestClose();
|
||||
addToast(`Moved to ${COLUMN_LABELS[column]}`, "success");
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
}
|
||||
},
|
||||
[task.id, task.steps, onMoveTask, onClose, addToast, confirm],
|
||||
[task.id, task.steps, onMoveTask, requestClose, addToast, confirm],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
@@ -1006,7 +1013,7 @@ export function TaskDetailModal({
|
||||
if (!shouldDelete) return;
|
||||
try {
|
||||
await onDeleteTask(task.id);
|
||||
onClose();
|
||||
requestClose();
|
||||
addToast(`Deleted ${task.id}`, "info");
|
||||
} catch (err) {
|
||||
const conflict = extractDependencyDeleteConflict(err);
|
||||
@@ -1029,13 +1036,13 @@ export function TaskDetailModal({
|
||||
|
||||
try {
|
||||
await onDeleteTask(task.id, { removeDependencyReferences: true });
|
||||
onClose();
|
||||
requestClose();
|
||||
addToast(`Deleted ${task.id} after removing dependency references`, "info");
|
||||
} catch (retryErr) {
|
||||
addToast(getErrorMessage(retryErr), "error");
|
||||
}
|
||||
}
|
||||
}, [task.id, onDeleteTask, onClose, addToast, confirm]);
|
||||
}, [task.id, onDeleteTask, requestClose, addToast, confirm]);
|
||||
|
||||
const handleMerge = useCallback(async () => {
|
||||
const shouldMerge = await confirm({
|
||||
@@ -1043,7 +1050,7 @@ export function TaskDetailModal({
|
||||
message: `Merge ${task.id} into the current branch?`,
|
||||
});
|
||||
if (!shouldMerge) return;
|
||||
onClose();
|
||||
requestClose();
|
||||
addToast(`Merging ${task.id}...`, "info");
|
||||
onMergeTask(task.id)
|
||||
.then((result) => {
|
||||
@@ -1055,11 +1062,11 @@ export function TaskDetailModal({
|
||||
.catch((err) => {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
});
|
||||
}, [task.id, onMergeTask, onClose, addToast, confirm]);
|
||||
}, [task.id, onMergeTask, requestClose, addToast, confirm]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
if (!onRetryTask) return;
|
||||
onClose();
|
||||
requestClose();
|
||||
onRetryTask(task.id)
|
||||
.then(() => {
|
||||
addToast(`Retried ${task.id}`, "success");
|
||||
@@ -1067,12 +1074,12 @@ export function TaskDetailModal({
|
||||
.catch((err) => {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
});
|
||||
}, [task.id, onRetryTask, onClose, addToast]);
|
||||
}, [task.id, onRetryTask, requestClose, addToast]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (!onResetTask) return;
|
||||
if (!window.confirm(`This will erase all progress for ${task.id} and start the task from scratch. Continue?`)) return;
|
||||
onClose();
|
||||
requestClose();
|
||||
onResetTask(task.id)
|
||||
.then(() => {
|
||||
addToast(`Reset ${task.id} — fresh run will be allocated`, "success");
|
||||
@@ -1080,7 +1087,7 @@ export function TaskDetailModal({
|
||||
.catch((err) => {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
});
|
||||
}, [task.id, onResetTask, onClose, addToast]);
|
||||
}, [task.id, onResetTask, requestClose, addToast]);
|
||||
|
||||
const handleDuplicate = useCallback(async () => {
|
||||
if (!onDuplicateTask) return;
|
||||
@@ -1091,12 +1098,12 @@ export function TaskDetailModal({
|
||||
if (!shouldDuplicate) return;
|
||||
try {
|
||||
const newTask = await onDuplicateTask(task.id);
|
||||
onClose();
|
||||
requestClose();
|
||||
addToast(`Duplicated ${task.id} → ${newTask.id}`, "success");
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
}
|
||||
}, [task.id, onDuplicateTask, onClose, addToast, confirm]);
|
||||
}, [task.id, onDuplicateTask, requestClose, addToast, confirm]);
|
||||
|
||||
const handleTogglePause = useCallback(async () => {
|
||||
try {
|
||||
@@ -1107,21 +1114,21 @@ export function TaskDetailModal({
|
||||
await pauseTask(task.id, projectId);
|
||||
addToast(`Paused ${task.id}`, "success");
|
||||
}
|
||||
onClose();
|
||||
requestClose();
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
}
|
||||
}, [task.id, task.paused, onClose, addToast]);
|
||||
}, [task.id, task.paused, requestClose, addToast]);
|
||||
|
||||
const handleApprovePlan = useCallback(async () => {
|
||||
try {
|
||||
await approvePlan(task.id, projectId);
|
||||
addToast(`Plan approved — ${task.id} moved to Todo`, "success");
|
||||
onClose();
|
||||
requestClose();
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
}
|
||||
}, [task.id, onClose, addToast]);
|
||||
}, [task.id, requestClose, addToast]);
|
||||
|
||||
const handleRejectPlan = useCallback(async () => {
|
||||
const shouldReject = await confirm({
|
||||
@@ -1133,11 +1140,11 @@ export function TaskDetailModal({
|
||||
try {
|
||||
await rejectPlan(task.id, projectId);
|
||||
addToast(`Plan rejected — ${task.id} returned to Planning for replanning`, "info");
|
||||
onClose();
|
||||
requestClose();
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
}
|
||||
}, [task.id, onClose, addToast, confirm]);
|
||||
}, [task.id, requestClose, addToast, confirm]);
|
||||
|
||||
const handleRespecify = useCallback(async () => {
|
||||
const shouldRebuild = await confirm({
|
||||
@@ -1147,12 +1154,12 @@ export function TaskDetailModal({
|
||||
if (!shouldRebuild) return;
|
||||
try {
|
||||
await rebuildTaskSpec(task.id, projectId);
|
||||
onClose();
|
||||
requestClose();
|
||||
addToast(`Replanning ${task.id}...`, "info");
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
}
|
||||
}, [task.id, projectId, onClose, addToast, confirm]);
|
||||
}, [task.id, projectId, requestClose, addToast, confirm]);
|
||||
|
||||
const handleOpenRefineModal = useCallback(() => {
|
||||
setShowRefineModal(true);
|
||||
@@ -1200,13 +1207,13 @@ export function TaskDetailModal({
|
||||
try {
|
||||
const newTask = await refineTask(task.id, refineFeedback.trim(), projectId);
|
||||
addToast(`Refinement task created: ${newTask.id}`, "success");
|
||||
onClose();
|
||||
requestClose();
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
} finally {
|
||||
setIsRefining(false);
|
||||
}
|
||||
}, [task.id, refineFeedback, addToast, onClose]);
|
||||
}, [task.id, refineFeedback, addToast, requestClose]);
|
||||
|
||||
const uploadFile = useCallback(async (file: File) => {
|
||||
setUploading(true);
|
||||
@@ -1387,7 +1394,7 @@ export function TaskDetailModal({
|
||||
await requestSpecRevision(task.id, feedback, projectId);
|
||||
addToast("AI revision requested. Task moved to planning.", "success");
|
||||
// Task has been moved to planning, close modal
|
||||
onClose();
|
||||
requestClose();
|
||||
} catch (err) {
|
||||
const msg = getErrorMessage(err);
|
||||
if (msg.includes("done") || msg.includes("archived")) {
|
||||
@@ -1398,7 +1405,7 @@ export function TaskDetailModal({
|
||||
} finally {
|
||||
setIsRequestingRevision(false);
|
||||
}
|
||||
}, [task.id, addToast, onClose]);
|
||||
}, [task.id, addToast, requestClose]);
|
||||
|
||||
// Spec editing handlers (depend on handleSaveSpec and handleRequestSpecRevision)
|
||||
const enterSpecEditMode = useCallback(() => {
|
||||
@@ -1548,9 +1555,12 @@ export function TaskDetailModal({
|
||||
const prAutomationLabel = task.status ? prAutomationStatusLabels[task.status] : undefined;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true">
|
||||
<div className="modal modal-lg task-detail-modal" ref={modalRef} onDragOver={handleDragOver} onDrop={handleDrop}>
|
||||
<div className="modal-header">
|
||||
<div
|
||||
className={embedded ? "task-detail-content task-detail-content--embedded" : "task-detail-content"}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<div className="detail-title-row">
|
||||
<span className="detail-id">{task.id}</span>
|
||||
<span className={`detail-column-badge badge-${task.column}`}>
|
||||
@@ -1568,9 +1578,11 @@ export function TaskDetailModal({
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
{!embedded && (
|
||||
<button className="modal-close" onClick={requestClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`detail-body${activeTab === "logs" && logSubview === "agent-log" && !isEditing ? " detail-body--agent-log" : ""}`}>
|
||||
@@ -2663,6 +2675,27 @@ export function TaskDetailModal({
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
useModalResizePersist(modalRef, true, "task-detail-modal-size");
|
||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay open"
|
||||
{...overlayDismissProps}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="modal modal-lg task-detail-modal" ref={modalRef}>
|
||||
<TaskDetailContent
|
||||
{...props}
|
||||
onRequestClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -53,7 +53,12 @@ const createMockTask = (overrides: Partial<Task> = {}): Task => ({
|
||||
const renderListView = (props: Partial<React.ComponentProps<typeof ListView>> = {}) => {
|
||||
const defaultProps = {
|
||||
tasks: [],
|
||||
onMoveTask: vi.fn(),
|
||||
onMoveTask: vi.fn(async () => createMockTask()),
|
||||
onRetryTask: vi.fn(async () => createMockTask()),
|
||||
onDeleteTask: vi.fn(async () => createMockTask()),
|
||||
onMergeTask: vi.fn(async () => ({ merged: false })),
|
||||
onResetTask: vi.fn(async () => createMockTask()),
|
||||
onDuplicateTask: vi.fn(async () => createMockTask()),
|
||||
onOpenDetail: vi.fn(),
|
||||
addToast: mockAddToast,
|
||||
globalPaused: false,
|
||||
@@ -117,6 +122,10 @@ function mockDesktopViewport() {
|
||||
describe("ListView", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetchTaskDetail).mockResolvedValue({
|
||||
...createMockTask(),
|
||||
prompt: "# Detail",
|
||||
} as TaskDetail);
|
||||
mockConfirm.mockReset();
|
||||
localStorage.clear();
|
||||
ensureMatchMedia();
|
||||
@@ -240,7 +249,7 @@ describe("ListView", () => {
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
});
|
||||
|
||||
it("updates selectedTaskId on desktop row click without opening detail", () => {
|
||||
it("updates selectedTaskId on desktop row click and mounts embedded detail", async () => {
|
||||
const viewportSpy = mockDesktopViewport();
|
||||
const tasks = [createMockTask({ id: "FN-001", title: "Test Task" })];
|
||||
const mockOnOpenDetail = vi.fn();
|
||||
@@ -252,9 +261,11 @@ describe("ListView", () => {
|
||||
|
||||
expect(mockOnOpenDetail).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBe("FN-001");
|
||||
expect(fetchTaskDetail).not.toHaveBeenCalled();
|
||||
expect(row?.className).toContain("list-row--selected");
|
||||
expect(screen.getByText("Task selected. Detail view coming soon.")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-001", TEST_PROJECT_ID);
|
||||
expect(screen.getByTestId("list-split-detail-content")).toBeInTheDocument();
|
||||
});
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -274,6 +285,76 @@ describe("ListView", () => {
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps embedded selection visible when filters hide the selected row", async () => {
|
||||
const viewportSpy = mockDesktopViewport();
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", title: "Alpha Task" }),
|
||||
createMockTask({ id: "FN-002", title: "Beta Task" }),
|
||||
];
|
||||
|
||||
const { rerender } = renderListView({ tasks, searchQuery: "Alpha" });
|
||||
|
||||
fireEvent.click(screen.getByText("FN-001").closest("tr")!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("list-split-detail-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(
|
||||
<ListView
|
||||
tasks={tasks}
|
||||
onMoveTask={vi.fn(async () => createMockTask())}
|
||||
onRetryTask={vi.fn(async () => createMockTask())}
|
||||
onDeleteTask={vi.fn(async () => createMockTask())}
|
||||
onMergeTask={vi.fn(async () => ({ merged: false }))}
|
||||
onResetTask={vi.fn(async () => createMockTask())}
|
||||
onDuplicateTask={vi.fn(async () => createMockTask())}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={mockAddToast}
|
||||
projectId={TEST_PROJECT_ID}
|
||||
searchQuery="Beta"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(document.querySelector('tr[data-id="FN-001"]')).toBeNull();
|
||||
expect(screen.getByTestId("list-split-detail-content")).toBeInTheDocument();
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps dependency navigation inline in embedded detail on desktop", async () => {
|
||||
const viewportSpy = mockDesktopViewport();
|
||||
const tasks = [createMockTask({ id: "FN-001", title: "Parent Task", dependencies: ["FN-002"] })];
|
||||
const mockOnOpenDetail = vi.fn();
|
||||
|
||||
vi.mocked(fetchTaskDetail)
|
||||
.mockResolvedValueOnce({
|
||||
...tasks[0],
|
||||
prompt: "# Parent detail",
|
||||
} as TaskDetail)
|
||||
.mockResolvedValueOnce({
|
||||
...createMockTask({ id: "FN-002", title: "Child Task" }),
|
||||
prompt: "# Child detail",
|
||||
} as TaskDetail);
|
||||
|
||||
renderListView({ tasks, onOpenDetail: mockOnOpenDetail });
|
||||
|
||||
fireEvent.click(screen.getByText("FN-001").closest("tr")!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("link", { name: /FN-002/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: /FN-002/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBe("FN-002");
|
||||
expect(fetchTaskDetail).toHaveBeenNthCalledWith(2, "FN-002", TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
expect(mockOnOpenDetail).not.toHaveBeenCalled();
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps selectedTaskIds and selectedTaskId as separate persisted state", async () => {
|
||||
const viewportSpy = mockDesktopViewport();
|
||||
const tasks = [createMockTask({ id: "FN-001", title: "Test Task" })];
|
||||
|
||||
@@ -5,7 +5,7 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TaskDetailModal } from "../TaskDetailModal";
|
||||
import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
|
||||
import type { TaskDetail, Column, MergeResult, Task } from "@fusion/core";
|
||||
import { clearAuthToken } from "../../auth";
|
||||
|
||||
@@ -261,6 +261,42 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders modal wrapper structure and close control", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask()}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".modal-overlay.open")).toBeTruthy();
|
||||
expect(container.querySelector(".modal.modal-lg.task-detail-modal")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits close control in embedded mode while rendering shared content", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailContent
|
||||
task={makeTask()}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
embedded
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".task-detail-content--embedded")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Close" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Definition" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("styles detail-body scrollbar rules", () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
@@ -1261,7 +1297,7 @@ describe("TaskDetailModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const modal = container.querySelector(".modal.modal-lg")!;
|
||||
const modal = container.querySelector(".task-detail-content")!;
|
||||
const imageFile = new File(["fake-image"], "dropped.png", { type: "image/png" });
|
||||
|
||||
await act(async () => {
|
||||
|
||||
Reference in New Issue
Block a user