feat(FN-3241): restore list view detail callback type compatibility

Fixes a type compatibility issue with the list view detail callback in the dashboard App component, restoring proper typing for the callback.

Fusion-Task-Id: FN-3241
This commit is contained in:
Fusion
2026-05-03 02:53:55 -07:00
committed by gsxdsm
parent 56a407b52c
commit 82ea9c8fd8
9 changed files with 94 additions and 11 deletions

View File

@@ -884,7 +884,7 @@ function AppInner() {
onMergeTask={mergeTask}
onResetTask={resetTask}
onDuplicateTask={duplicateTask}
onOpenDetail={modalManager.openDetailTask}
onOpenDetail={(task, options) => modalManager.openDetailTask(task, undefined, options)}
addToast={addToast}
globalPaused={globalPaused}
onNewTask={modalManager.openNewTask}

View File

@@ -164,6 +164,7 @@ export function AppModals({
tasks={tasks}
onClose={deepLink.handleDetailClose}
onOpenDetail={modalManager.openDetailTask}
mobileHeaderMode={modalManager.detailTaskOrigin === "list-mobile" ? "back" : "close"}
onMoveTask={taskOperations.moveTask}
onDeleteTask={taskOperations.deleteTask}
onMergeTask={taskOperations.mergeTask}

View File

@@ -164,7 +164,7 @@ interface ListViewProps {
onMergeTask: (id: string) => Promise<MergeResult>;
onResetTask?: (id: string) => Promise<Task>;
onDuplicateTask?: (id: string) => Promise<Task>;
onOpenDetail: (task: Task | TaskDetail) => void;
onOpenDetail: (task: Task | TaskDetail, options?: { origin?: "list-mobile" }) => void;
addToast: (message: string, type?: ToastType) => void;
globalPaused?: boolean;
onNewTask?: () => void;
@@ -755,7 +755,7 @@ export function ListView({
const handleRowClick = useCallback(
(task: Task) => {
if (isMobile) {
onOpenDetail(task);
onOpenDetail(task, { origin: "list-mobile" });
return;
}

View File

@@ -1208,6 +1208,21 @@
gap: var(--space-sm);
}
.task-detail-mobile-back {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
min-height: calc(var(--space-2xl) + var(--space-xs));
padding: 0 var(--space-sm);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.625);
line-height: 1;
}
.task-detail-mobile-back svg {
width: 1em;
height: 1em;
}
.modal-edit-btn {
display: flex;
align-items: center;
@@ -1517,4 +1532,9 @@
min-height: 36px;
}
.task-detail-mobile-back {
min-height: calc(var(--space-2xl) + var(--space-xs));
min-width: calc(var(--space-2xl) + var(--space-xs));
}
}

View File

@@ -1,6 +1,6 @@
import "./TaskDetailModal.css";
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch } from "lucide-react";
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft } from "lucide-react";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import ReactMarkdown from "react-markdown";
@@ -167,6 +167,8 @@ export interface TaskDetailModalProps {
prAuthAvailable?: boolean;
/** Open the modal with this tab active instead of "definition" */
initialTab?: TabId;
/** Mobile-only header affordance mode. */
mobileHeaderMode?: "close" | "back";
}
export type TaskDetailContentProps = Omit<TaskDetailModalProps, "onClose"> & {
@@ -332,6 +334,7 @@ export function TaskDetailContent({
addToast,
prAuthAvailable,
initialTab = "definition",
mobileHeaderMode = "close",
embedded = false,
onRequestClose,
}: TaskDetailContentProps) {
@@ -1578,8 +1581,19 @@ export function TaskDetailContent({
<Pencil size={14} />
</button>
)}
{!embedded && (
<button className="modal-close" onClick={requestClose} aria-label="Close">
{!embedded && mobileHeaderMode === "back" && (
<button
className="modal-close task-detail-mobile-back"
onClick={requestClose}
aria-label="Back to task list"
type="button"
>
<ArrowLeft aria-hidden="true" />
<span>Back</span>
</button>
)}
{!embedded && mobileHeaderMode !== "back" && (
<button className="modal-close" onClick={requestClose} aria-label="Close" type="button">
&times;
</button>
)}

View File

@@ -279,7 +279,7 @@ describe("ListView", () => {
const card = document.querySelector('.list-card[data-id="FN-001"]');
fireEvent.click(card!);
expect(mockOnOpenDetail).toHaveBeenCalledWith(tasks[0]);
expect(mockOnOpenDetail).toHaveBeenCalledWith(tasks[0], { origin: "list-mobile" });
expect(mockOnOpenDetail).toHaveBeenCalledTimes(1);
expect(fetchTaskDetail).not.toHaveBeenCalled();
viewportSpy.mockRestore();
@@ -2748,7 +2748,7 @@ describe("ListView - Bulk Selection", () => {
fireEvent.click(container.querySelector('.list-card[data-id="FN-001"]') as HTMLElement);
// Should call onOpenDetail synchronously with the Task object (no fetch)
expect(mockOnOpenDetail).toHaveBeenCalledWith(task);
expect(mockOnOpenDetail).toHaveBeenCalledWith(task, { origin: "list-mobile" });
expect(mockOnOpenDetail).toHaveBeenCalledTimes(1);
});

View File

@@ -51,6 +51,7 @@ vi.mock("lucide-react", () => ({
ChevronUp: () => null,
ChevronDown: () => null,
ChevronRight: (props: any) => <svg data-testid="chevron-right-icon" {...props} />,
ArrowLeft: () => null,
X: () => null,
Maximize2: () => null,
Minimize2: () => null,
@@ -261,7 +262,7 @@ describe("TaskDetailModal", () => {
});
});
it("renders modal wrapper structure and close control", () => {
it("renders modal wrapper structure and default close control", () => {
const { container } = render(
<TaskDetailModal
task={makeTask()}
@@ -277,6 +278,25 @@ describe("TaskDetailModal", () => {
expect(container.querySelector(".modal-overlay.open")).toBeTruthy();
expect(container.querySelector(".modal.modal-lg.task-detail-modal")).toBeTruthy();
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Back to task list" })).toBeNull();
});
it("renders mobile back control variant when requested", () => {
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
mobileHeaderMode="back"
/>,
);
expect(screen.getByRole("button", { name: "Back to task list" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Close" })).toBeNull();
});
it("omits close control in embedded mode while rendering shared content", () => {

View File

@@ -198,6 +198,18 @@ describe("core modals mobile css coverage", () => {
expect(menuBlock).toContain("max-width: calc(100vw - 28px)");
});
it("TaskDetailModal: mobile back control keeps token-based touch-target sizing", () => {
const css = loadAllAppCss();
const mobileBlock = getMainMobileBlock(css);
const backControlMatch = mobileBlock.match(
/\.task-detail-mobile-back\s*\{[^}]+\}/,
);
expect(backControlMatch).not.toBeNull();
expect(backControlMatch![0]).toContain("min-height: calc(var(--space-2xl) + var(--space-xs))");
expect(backControlMatch![0]).toContain("min-width: calc(var(--space-2xl) + var(--space-xs))");
});
it("TaskDetailModal: footer dropdown menus anchor toward available horizontal space", () => {
const css = loadAllAppCss();

View File

@@ -11,6 +11,8 @@ export type DetailTaskTab =
| "model"
| "workflow";
export type DetailTaskOrigin = "list-mobile";
interface UseModalManagerOptions {
projectId?: string;
planningSessions: Array<{ id: string }>;
@@ -31,6 +33,7 @@ export interface ModalManager {
// Can be Task (optimistic open) or TaskDetail (full data with prompt)
detailTask: (Task | TaskDetail) | null;
detailTaskInitialTab: DetailTaskTab;
detailTaskOrigin: DetailTaskOrigin | null;
settingsOpen: boolean;
settingsInitialSection: SectionId | undefined;
schedulesOpen: boolean;
@@ -66,7 +69,11 @@ export interface ModalManager {
openSubtaskWithSession: (sessionId: string) => void;
closeSubtask: () => void;
openDetailTask: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
openDetailTask: (
task: Task | TaskDetail,
initialTab?: DetailTaskTab,
options?: { origin?: DetailTaskOrigin },
) => void;
openDetailWithChangesTab: (task: Task | TaskDetail) => void;
updateDetailTask: (updated: Partial<TaskDetail>) => void;
closeDetailTask: () => void;
@@ -141,6 +148,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
// Can be Task (optimistic open) or TaskDetail (full data with prompt)
const [detailTask, setDetailTask] = useState<(Task | TaskDetail) | null>(null);
const [detailTaskInitialTab, setDetailTaskInitialTab] = useState<DetailTaskTab>("definition");
const [detailTaskOrigin, setDetailTaskOrigin] = useState<DetailTaskOrigin | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
const [schedulesOpen, setSchedulesOpen] = useState(false);
@@ -221,19 +229,26 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
setSubtaskResumeSessionId(undefined);
}, []);
const openDetailTask = useCallback((task: Task | TaskDetail, initialTab: DetailTaskTab = "definition") => {
const openDetailTask = useCallback((
task: Task | TaskDetail,
initialTab: DetailTaskTab = "definition",
options?: { origin?: DetailTaskOrigin },
) => {
setDetailTask(task);
setDetailTaskInitialTab(initialTab);
setDetailTaskOrigin(options?.origin ?? null);
}, []);
const openDetailWithChangesTab = useCallback((task: Task | TaskDetail) => {
setDetailTask(task);
setDetailTaskInitialTab("changes");
setDetailTaskOrigin(null);
}, []);
const updateDetailTask = useCallback((updated: Partial<TaskDetail>) => {
setDetailTask((prev) => (prev ? { ...prev, ...updated } : prev));
}, []);
const closeDetailTask = useCallback(() => {
setDetailTask(null);
setDetailTaskOrigin(null);
}, []);
const openSettings = useCallback((section?: SectionId) => {
@@ -335,6 +350,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
subtaskResumeSessionId,
detailTask,
detailTaskInitialTab,
detailTaskOrigin,
settingsOpen,
settingsInitialSection,
schedulesOpen,