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 d4c534a8dd
commit 2ce4351724
8 changed files with 320 additions and 62 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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