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