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:
Fusion
2026-05-03 00:52:12 -07:00
committed by gsxdsm
parent 2b5c27814b
commit e392770be3
8 changed files with 320 additions and 62 deletions

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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>
</>

View File

@@ -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;
}

View File

@@ -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">
&times;
</button>
{!embedded && (
<button className="modal-close" onClick={requestClose} aria-label="Close">
&times;
</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>
);

View File

@@ -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" })];

View File

@@ -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 () => {