feat(FN-1328): enable optimistic task detail modal opening

- Update useModalManager to accept Task objects for immediate modal display before full detail loads
- Refactor TaskDetailModal to render with basic Task data and load TaskDetail asynchronously
- Update TaskCard click handlers to open modal immediately with optimistic data
- Update ListView click handler for same optimistic opening behavior
- Update AppModals types to support Task | TaskDetail union
- Add CSS for modal loading/skeleton states
- Add comprehensive tests for TaskDetailModal and useModalManager optimistic flow
- Simplify TaskCard and ListView tests to reflect new optimistic opening pattern
This commit is contained in:
gsxdsm
2026-04-08 22:21:24 -07:00
parent 12a6da53f1
commit 6388c40479
10 changed files with 418 additions and 110 deletions

View File

@@ -1,5 +1,5 @@
import type { ProjectInfo } from "../api";
import type { ColorTheme, Column, MergeResult, Task, TaskCreateInput, TaskDetail, ThemeMode } from "@fusion/core";
import type { ColorTheme, Column, MergeResult, Task, TaskCreateInput, ThemeMode } from "@fusion/core";
import type { UseProjectActionsResult } from "../hooks/useProjectActions";
import type { ModalManager } from "../hooks/useModalManager";
import type { UseTaskHandlersResult } from "../hooks/useTaskHandlers";
@@ -199,7 +199,7 @@ export function AppModals({
onOpenTaskDetail={(taskId) => {
const task = tasks.find((candidate) => candidate.id === taskId);
if (task) {
modalManager.openDetailTask(task as TaskDetail);
modalManager.openDetailTask(task);
}
}}
/>

View File

@@ -2,7 +2,7 @@ import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "rea
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, EyeOff, Eye, ChevronRight } from "lucide-react";
import type { Task, TaskDetail, Column, TaskStep, TaskCreateInput } from "@fusion/core";
import { COLUMN_LABELS, COLUMNS } from "@fusion/core";
import { fetchTaskDetail, batchUpdateTaskModels } from "../api";
import { batchUpdateTaskModels } from "../api";
import type { ModelInfo } from "../api";
import { QuickEntryBox } from "./QuickEntryBox";
import { CustomModelDropdown } from "./CustomModelDropdown";
@@ -97,7 +97,7 @@ function readSelectedTaskIds(projectId?: string): Set<string> {
interface ListViewProps {
tasks: Task[];
onMoveTask: (id: string, column: Column) => Promise<Task>;
onOpenDetail: (task: TaskDetail) => void;
onOpenDetail: (task: Task | TaskDetail) => void;
addToast: (message: string, type?: ToastType) => void;
globalPaused?: boolean;
onNewTask?: () => void;
@@ -523,15 +523,10 @@ export function ListView({
}, [selectedTaskIds, tasks, executorModel, validatorModel, projectId, addToast, clearSelection, onTasksUpdated]);
const handleRowClick = useCallback(
async (task: Task) => {
try {
const detail = await fetchTaskDetail(task.id, projectId);
onOpenDetail(detail);
} catch (err: any) {
addToast("Failed to load task details", "error");
}
(task: Task) => {
onOpenDetail(task);
},
[onOpenDetail, addToast, projectId]
[onOpenDetail]
);
const handleDragStart = useCallback(

View File

@@ -77,7 +77,7 @@ interface TaskCardProps {
task: Task;
projectId?: string;
queued?: boolean;
onOpenDetail: (task: TaskDetail) => void;
onOpenDetail: (task: Task | TaskDetail) => void;
addToast: (message: string, type?: ToastType) => void;
globalPaused?: boolean;
onUpdateTask?: (
@@ -86,7 +86,7 @@ interface TaskCardProps {
) => Promise<Task>;
onArchiveTask?: (id: string) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
onOpenDetailWithTab?: (task: TaskDetail, initialTab: "changes") => void;
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes") => void;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
/** Called when user clicks the mission badge on a task card. */
@@ -336,15 +336,10 @@ function TaskCardComponent({
}
}, [task.id, isFileDrag, addToast]);
const handleClick = useCallback(async () => {
const handleClick = useCallback(() => {
if (isEditing) return; // Don't open detail when editing
try {
const detail = await fetchTaskDetail(task.id, projectId);
onOpenDetail(detail);
} catch {
addToast("Failed to load task details", "error");
}
}, [task.id, onOpenDetail, addToast, isEditing]);
onOpenDetail(task);
}, [task, onOpenDetail, isEditing]);
const handleCardClick = useCallback((e: React.MouseEvent) => {
if (touchOpenHandledRef.current) {
@@ -591,15 +586,10 @@ function TaskCardComponent({
});
}, [addToast, onUnarchiveTask, task.id]);
const handleOpenFiles = useCallback(async (e: React.MouseEvent) => {
const handleOpenFiles = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
try {
const detail = await fetchTaskDetail(task.id, projectId);
onOpenDetailWithTab?.(detail, "changes");
} catch {
addToast("Failed to load task details", "error");
}
}, [task.id, projectId, onOpenDetailWithTab, addToast]);
onOpenDetailWithTab?.(task, "changes");
}, [task, onOpenDetailWithTab]);
const handleToggleSteps = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();

View File

@@ -170,11 +170,11 @@ function formatBytes(bytes: number): string {
type TabId = "definition" | "logs" | "changes" | "commits" | "comments" | "model" | "workflow";
interface TaskDetailModalProps {
task: TaskDetail;
task: Task | TaskDetail;
projectId?: string;
tasks?: Task[];
onClose: () => void;
onOpenDetail: (task: TaskDetail) => void; // For clicking dependencies
onOpenDetail: (task: Task | TaskDetail) => void; // For clicking dependencies
onMoveTask: (id: string, column: Column) => Promise<Task>;
onDeleteTask: (id: string) => Promise<Task>;
onMergeTask: (id: string) => Promise<MergeResult>;
@@ -211,6 +211,49 @@ export function TaskDetailModal({
}: TaskDetailModalProps) {
const [activeTab, setActiveTab] = useState<TabId>(initialTab);
// ── Async detail loading ──────────────────────────────────────────────────
// When opened optimistically with a Task (no prompt), fetch the full
// TaskDetail in the background. The modal renders immediately with the
// lightweight data and shows a loading indicator in the spec section.
const [fullDetail, setFullDetail] = useState<TaskDetail | null>(() =>
"prompt" in task ? (task as TaskDetail) : null,
);
const [detailLoading, setDetailLoading] = useState(() =>
!("prompt" in task),
);
useEffect(() => {
// If the prop already has a prompt field, it's a full TaskDetail
if ("prompt" in task) {
setFullDetail(task as TaskDetail);
setDetailLoading(false);
return;
}
let cancelled = false;
setDetailLoading(true);
setFullDetail(null);
fetchTaskDetail(task.id, projectId)
.then((detail) => {
if (!cancelled) {
setFullDetail(detail);
setDetailLoading(false);
}
})
.catch(() => {
if (!cancelled) {
setDetailLoading(false);
}
});
return () => { cancelled = true; };
}, [task.id, projectId]);
// Derive a working task that always has all available fields.
// Falls back to the optimistic Task while loading, uses fullDetail once loaded.
const workingTask: TaskDetail = fullDetail ?? { ...task, prompt: "" } as TaskDetail;
// Sync activeTab when the caller changes initialTab (e.g. opening a different tab)
useEffect(() => {
setActiveTab(initialTab);
@@ -229,7 +272,7 @@ export function TaskDetailModal({
const [isSavingSpec, setIsSavingSpec] = useState(false);
const [isRequestingRevision, setIsRequestingRevision] = useState(false);
const [isEditingSpec, setIsEditingSpec] = useState(false);
const [specEditContent, setSpecEditContent] = useState(task.prompt || "");
const [specEditContent, setSpecEditContent] = useState(workingTask.prompt || "");
const [specFeedback, setSpecFeedback] = useState("");
const [showRefineModal, setShowRefineModal] = useState(false);
const [refineFeedback, setRefineFeedback] = useState("");
@@ -350,9 +393,9 @@ export function TaskDetailModal({
// Reset spec edit state when task changes
useEffect(() => {
setIsEditingSpec(false);
setSpecEditContent(task.prompt || "");
setSpecEditContent(workingTask.prompt || "");
setSpecFeedback("");
}, [task.id, task.prompt]);
}, [task.id, workingTask.prompt]);
// Note: TaskForm handles auto-focus internally via isActive prop
@@ -789,17 +832,19 @@ export function TaskDetailModal({
const handleSaveSpec = useCallback(async (newContent: string) => {
setIsSavingSpec(true);
try {
await updateTask(task.id, { prompt: newContent }, projectId);
await updateTask(workingTask.id, { prompt: newContent }, projectId);
addToast("Spec updated", "success");
// Update local task data
task.prompt = newContent;
// Update local detail data
if (fullDetail) {
fullDetail.prompt = newContent;
}
} catch (err: any) {
addToast(err.message, "error");
throw err;
} finally {
setIsSavingSpec(false);
}
}, [task, addToast]);
}, [workingTask, fullDetail, addToast]);
const handleRequestSpecRevision = useCallback(async (feedback: string) => {
setIsRequestingRevision(true);
@@ -822,24 +867,24 @@ export function TaskDetailModal({
// Spec editing handlers (depend on handleSaveSpec and handleRequestSpecRevision)
const enterSpecEditMode = useCallback(() => {
setIsEditingSpec(true);
setSpecEditContent(task.prompt || "");
setSpecEditContent(workingTask.prompt || "");
setSpecFeedback("");
}, [task.prompt]);
}, [workingTask.prompt]);
const exitSpecEditMode = useCallback(() => {
setIsEditingSpec(false);
setSpecEditContent(task.prompt || "");
setSpecEditContent(workingTask.prompt || "");
setSpecFeedback("");
}, [task.prompt]);
}, [workingTask.prompt]);
const handleSaveSpecFromEdit = useCallback(async () => {
if (specEditContent === (task.prompt || "")) {
if (specEditContent === (workingTask.prompt || "")) {
exitSpecEditMode();
return;
}
await handleSaveSpec(specEditContent);
setIsEditingSpec(false);
}, [specEditContent, task.prompt, handleSaveSpec, exitSpecEditMode]);
}, [specEditContent, workingTask.prompt, handleSaveSpec, exitSpecEditMode]);
const handleRequestRevisionFromEdit = useCallback(async () => {
if (!specFeedback.trim()) return;
@@ -1196,7 +1241,7 @@ export function TaskDetailModal({
<button
className="btn btn-primary btn-sm"
onClick={() => void handleSaveSpecFromEdit()}
disabled={specEditContent === (task.prompt || "") || isSavingSpec}
disabled={specEditContent === (workingTask.prompt || "") || isSavingSpec}
>
{isSavingSpec ? "Saving…" : "Save"}
</button>
@@ -1233,10 +1278,12 @@ export function TaskDetailModal({
</div>
</div>
</div>
) : task.prompt ? (
) : detailLoading ? (
<div className="spec-loading">Loading specification</div>
) : workingTask.prompt ? (
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{task.prompt.replace(/^#\s+[^\n]*\n+/, "")}
{workingTask.prompt.replace(/^#\s+[^\n]*\n+/, "")}
</ReactMarkdown>
</div>
) : (
@@ -1455,7 +1502,7 @@ export function TaskDetailModal({
</button>
)}
{/* Approve/Reject Plan buttons for tasks awaiting approval */}
{task.column === "triage" && task.status === "awaiting-approval" && task.prompt && (
{task.column === "triage" && task.status === "awaiting-approval" && workingTask.prompt && (
<>
<button className="btn btn-primary btn-sm" onClick={handleApprovePlan}>
Approve Plan

View File

@@ -204,42 +204,30 @@ describe("ListView", () => {
expect(screen.getByText("FN-002")).toBeDefined();
});
it("calls onOpenDetail when row is clicked", async () => {
it("calls onOpenDetail synchronously with Task when row is clicked", async () => {
const tasks = [createMockTask({ id: "FN-001", title: "Test Task" })];
const mockOnOpenDetail = vi.fn();
const mockDetail: TaskDetail = {
...tasks[0],
prompt: "Test prompt",
};
(fetchTaskDetail as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockDetail);
renderListView({ tasks, onOpenDetail: mockOnOpenDetail });
const row = screen.getByText("FN-001").closest("tr");
fireEvent.click(row!);
await waitFor(() => {
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-001", TEST_PROJECT_ID);
});
expect(mockOnOpenDetail).toHaveBeenCalledWith(mockDetail);
// Should call onOpenDetail synchronously with the Task object (no fetch)
expect(mockOnOpenDetail).toHaveBeenCalledWith(tasks[0]);
expect(mockOnOpenDetail).toHaveBeenCalledTimes(1);
});
it("shows error toast when fetchTaskDetail fails", async () => {
it("does not call fetchTaskDetail on row click", () => {
const tasks = [createMockTask({ id: "FN-001", title: "Test Task" })];
const mockOnOpenDetail = vi.fn();
(fetchTaskDetail as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Network error"));
renderListView({ tasks, onOpenDetail: mockOnOpenDetail });
const row = screen.getByText("FN-001").closest("tr");
fireEvent.click(row!);
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith("Failed to load task details", "error");
});
expect(fetchTaskDetail).not.toHaveBeenCalled();
});
it("sorts tasks by ID when ID header is clicked", () => {
@@ -2212,12 +2200,6 @@ describe("ListView - Bulk Selection", () => {
mockMobileViewport();
const task = createMockTask({ id: "FN-001", title: "Open me" });
const mockOnOpenDetail = vi.fn();
const detail: TaskDetail = {
...task,
prompt: "Prompt content",
};
(fetchTaskDetail as ReturnType<typeof vi.fn>).mockResolvedValueOnce(detail);
const { container } = renderListView({
tasks: [task],
@@ -2226,10 +2208,9 @@ describe("ListView - Bulk Selection", () => {
fireEvent.click(container.querySelector('.list-card[data-id="FN-001"]') as HTMLElement);
await waitFor(() => {
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-001", TEST_PROJECT_ID);
});
expect(mockOnOpenDetail).toHaveBeenCalledWith(detail);
// Should call onOpenDetail synchronously with the Task object (no fetch)
expect(mockOnOpenDetail).toHaveBeenCalledWith(task);
expect(mockOnOpenDetail).toHaveBeenCalledTimes(1);
});
it("collapses and expands mobile section headers", () => {

View File

@@ -2532,15 +2532,7 @@ describe("TaskCard detail opening", () => {
vi.clearAllMocks();
});
it("opens modal when clicking the card body", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
const mockDetail: TaskDetail = {
...makeTask({ id: "FN-099" }),
prompt: "",
attachments: [],
};
mockFetch.mockResolvedValueOnce(mockDetail);
it("opens modal immediately with Task when clicking the card body", async () => {
const onOpenDetail = vi.fn();
const task = makeTask();
@@ -2559,21 +2551,12 @@ describe("TaskCard detail opening", () => {
const cardTitle = screen.getByText("Test task");
fireEvent.click(cardTitle);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("FN-099", undefined);
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
});
// Should call onOpenDetail synchronously with the Task object (no fetch)
expect(onOpenDetail).toHaveBeenCalledWith(task);
expect(onOpenDetail).toHaveBeenCalledTimes(1);
});
it("opens modal only once per card click", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
const mockDetail: TaskDetail = {
...makeTask({ id: "FN-099" }),
prompt: "",
attachments: [],
};
mockFetch.mockResolvedValueOnce(mockDetail);
const onOpenDetail = vi.fn();
const task = makeTask();
@@ -2587,11 +2570,9 @@ describe("TaskCard detail opening", () => {
fireEvent.click(screen.getByText("Test task"));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("FN-099", undefined);
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
expect(onOpenDetail).toHaveBeenCalledTimes(1);
});
// Should call onOpenDetail synchronously with the Task object (no fetch)
expect(onOpenDetail).toHaveBeenCalledWith(task);
expect(onOpenDetail).toHaveBeenCalledTimes(1);
});
it("does NOT open modal during vertical scrolling", async () => {

View File

@@ -4464,4 +4464,217 @@ describe("TaskDetailModal", () => {
});
});
});
describe("optimistic opening with Task", () => {
beforeEach(async () => {
const { fetchTaskDetail } = await import("../../api");
vi.mocked(fetchTaskDetail).mockReset();
});
it("renders immediately when opened with a Task prop (no prompt)", async () => {
const { fetchTaskDetail } = await import("../../api");
vi.mocked(fetchTaskDetail).mockResolvedValueOnce({
id: "FN-200",
description: "Optimistic task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
prompt: "# Spec",
} as TaskDetail);
const task: Task = {
id: "FN-200",
description: "Optimistic task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
} as Task;
const { container } = render(
<TaskDetailModal
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Modal renders immediately without crashing
expect(container.querySelector(".modal-overlay")).toBeTruthy();
expect(screen.getByText("FN-200")).toBeDefined();
});
it("calls fetchTaskDetail on mount when prop is Task without prompt", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
mockFetch.mockResolvedValueOnce({
id: "FN-201",
description: "Optimistic task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
prompt: "# Spec",
} as TaskDetail);
const task: Task = {
id: "FN-201",
description: "Optimistic task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
} as Task;
render(
<TaskDetailModal
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("FN-201", undefined);
});
});
it("does NOT call fetchTaskDetail when prop is already a TaskDetail with prompt", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
const detail: TaskDetail = {
id: "FN-202",
description: "Full detail task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
prompt: "# Full spec",
} as TaskDetail;
render(
<TaskDetailModal
task={detail}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Give a tick for any async operations
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockFetch).not.toHaveBeenCalledWith("FN-202", undefined);
});
it("shows loading state in spec area when detailLoading is true", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
// Set up a pending promise so loading state persists
mockFetch.mockReturnValueOnce(new Promise(() => {}));
const task: Task = {
id: "FN-203",
description: "Loading spec test",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
} as Task;
render(
<TaskDetailModal
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Loading specification…")).toBeDefined();
});
it("shows spec content after fetchTaskDetail resolves", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
const task: Task = {
id: "FN-204",
description: "Async spec test",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
} as Task;
const fullDetail: TaskDetail = {
...task,
prompt: "# Async Spec\n\nThis is the loaded spec content.",
} as TaskDetail;
// Resolve with full detail
mockFetch.mockResolvedValueOnce(fullDetail);
const { container } = render(
<TaskDetailModal
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Initially shows loading
expect(screen.getByText("Loading specification…")).toBeDefined();
// After fetch resolves, spec content appears
await waitFor(() => {
const markdownBody = container.querySelector(".markdown-body");
expect(markdownBody).toBeTruthy();
}, { timeout: 3000 });
// Loading indicator should be gone
expect(screen.queryByText("Loading specification…")).toBeNull();
});
});
});

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { TaskDetail } from "@fusion/core";
import type { Task, TaskDetail } from "@fusion/core";
import { useModalManager } from "../useModalManager";
import * as api from "../../api";
@@ -29,9 +29,30 @@ function createTaskDetail(id: string): TaskDetail {
size: "M",
reviewLevel: 1,
steeringComments: [],
prompt: "# Task spec",
} as TaskDetail;
}
function createTask(id: string): Task {
return {
id,
title: `Task ${id}`,
description: "desc",
column: "todo",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
columnMovedAt: new Date().toISOString(),
dependencies: [],
steps: [],
currentStep: 0,
log: [],
attachments: [],
size: "M",
reviewLevel: 1,
steeringComments: [],
} as Task;
}
describe("useModalManager", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -179,4 +200,58 @@ describe("useModalManager", () => {
expect(result.current.settingsOpen).toBe(false);
expect(result.current.settingsInitialSection).toBeUndefined();
});
it("accepts plain Task object for optimistic modal opening", () => {
const task = createTask("FN-456");
const { result } = renderHook(() =>
useModalManager({ projectId: "proj_1", planningSessions: [] }),
);
act(() => {
result.current.openDetailTask(task);
});
expect(result.current.detailTask?.id).toBe("FN-456");
// Should not have prompt field (plain Task)
expect("prompt" in (result.current.detailTask as Record<string, unknown>)).toBe(false);
expect(result.current.detailTaskInitialTab).toBe("definition");
});
it("accepts plain Task object in openDetailWithChangesTab", () => {
const task = createTask("FN-789");
const { result } = renderHook(() =>
useModalManager({ projectId: "proj_1", planningSessions: [] }),
);
act(() => {
result.current.openDetailWithChangesTab(task);
});
expect(result.current.detailTask?.id).toBe("FN-789");
expect(result.current.detailTaskInitialTab).toBe("changes");
});
it("holds Task object in detailTask state correctly", () => {
const task = createTask("FN-100");
const { result } = renderHook(() =>
useModalManager({ projectId: "proj_1", planningSessions: [] }),
);
act(() => {
result.current.openDetailTask(task);
});
// State should hold the Task object with all its fields
const detailTask = result.current.detailTask;
expect(detailTask).not.toBeNull();
expect(detailTask!.id).toBe("FN-100");
expect(detailTask!.title).toBe("Task FN-100");
expect(detailTask!.column).toBe("todo");
// Can be closed and state resets
act(() => {
result.current.closeDetailTask();
});
expect(result.current.detailTask).toBeNull();
});
});

View File

@@ -30,7 +30,8 @@ export interface ModalManager {
isSubtaskOpen: boolean;
subtaskInitialDescription: string | null;
subtaskResumeSessionId: string | undefined;
detailTask: TaskDetail | null;
// Can be Task (optimistic open) or TaskDetail (full data with prompt)
detailTask: (Task | TaskDetail) | null;
detailTaskInitialTab: DetailTaskTab;
settingsOpen: boolean;
settingsInitialSection: SectionId | undefined;
@@ -67,8 +68,8 @@ export interface ModalManager {
openSubtaskWithSession: (sessionId: string) => void;
closeSubtask: () => void;
openDetailTask: (task: TaskDetail, initialTab?: DetailTaskTab) => void;
openDetailWithChangesTab: (task: TaskDetail) => void;
openDetailTask: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
openDetailWithChangesTab: (task: Task | TaskDetail) => void;
updateDetailTask: (updated: Partial<TaskDetail>) => void;
closeDetailTask: () => void;
@@ -137,7 +138,8 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
const [isSubtaskOpen, setIsSubtaskOpen] = useState(false);
const [subtaskInitialDescription, setSubtaskInitialDescription] = useState<string | null>(null);
const [subtaskResumeSessionId, setSubtaskResumeSessionId] = useState<string | undefined>(undefined);
const [detailTask, setDetailTask] = useState<TaskDetail | null>(null);
// 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 [settingsOpen, setSettingsOpen] = useState(false);
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
@@ -218,11 +220,11 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
setSubtaskResumeSessionId(undefined);
}, []);
const openDetailTask = useCallback((task: TaskDetail, initialTab: DetailTaskTab = "definition") => {
const openDetailTask = useCallback((task: Task | TaskDetail, initialTab: DetailTaskTab = "definition") => {
setDetailTask(task);
setDetailTaskInitialTab(initialTab);
}, []);
const openDetailWithChangesTab = useCallback((task: TaskDetail) => {
const openDetailWithChangesTab = useCallback((task: Task | TaskDetail) => {
setDetailTask(task);
setDetailTaskInitialTab("changes");
}, []);

View File

@@ -4162,6 +4162,30 @@ body {
color: var(--text-muted);
}
.spec-loading {
padding: 14px;
font-size: 13px;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 8px;
}
.spec-loading::before {
content: "";
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top-color: var(--text-secondary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* === Markdown Prose === */
.markdown-body {
font-size: 13px;