Files
fusion/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx
Fusion 681aa5ff8c feat(FN-3066): extend plugin UI slot metadata and fix settings token fallba
This release (v0.15.0) introduces plugin dashboard views and a new dependency graph plugin, extends the plugin slot system with richer metadata surfaces and UX improvements, adds SQLite WAL tuning with integrity checks, and stabilizes agent run log streaming with enriched session labels. It also fix

Fusion-Task-Id: FN-3066
2026-05-01 23:46:43 -07:00

6256 lines
218 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { loadAllAppCss } from "../../test/cssFixture";
import { useState } from "react";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TaskDetailModal } from "../TaskDetailModal";
import type { TaskDetail, Column, MergeResult, Task } from "@fusion/core";
import { clearAuthToken } from "../../auth";
vi.mock("../../api", async (importOriginal) => {
const { createDashboardApiMock } = await import("../../test/mockApi");
return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
uploadAttachment: vi.fn(),
deleteAttachment: vi.fn(),
updateTask: vi.fn().mockResolvedValue({}),
fetchTaskDetail: vi.fn().mockResolvedValue(makeTask()),
fetchAgentLogs: vi.fn().mockResolvedValue([]),
requestSpecRevision: vi.fn().mockResolvedValue({}),
approvePlan: vi.fn().mockResolvedValue({}),
rejectPlan: vi.fn().mockResolvedValue({}),
duplicateTask: vi.fn().mockResolvedValue({}),
refineTask: vi.fn().mockResolvedValue({}),
addSteeringComment: vi.fn(),
assignTask: vi.fn().mockResolvedValue({}),
fetchAgents: vi.fn().mockResolvedValue([]),
fetchAgent: vi.fn(),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [] }),
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
refineText: vi.fn(),
getRefineErrorMessage: vi.fn((err: any) => err?.message || "Failed to refine"),
updateGlobalSettings: vi.fn().mockResolvedValue({}),
pauseTask: vi.fn().mockResolvedValue({}),
unpauseTask: vi.fn().mockResolvedValue({}),
fetchWorkflowResults: vi.fn().mockResolvedValue([]),
});
});
// Mock lucide-react icons used by TaskDetailModal, TaskForm, PrSection, CustomModelDropdown
vi.mock("lucide-react", () => ({
Pencil: () => null,
Sparkles: () => null,
Globe: () => null,
GitPullRequest: () => null,
ExternalLink: () => null,
RefreshCw: () => null,
Plus: () => null,
MessageSquare: () => null,
ChevronUp: () => null,
ChevronDown: () => null,
ChevronRight: (props: any) => <svg data-testid="chevron-right-icon" {...props} />,
X: () => null,
Maximize2: () => null,
Minimize2: () => null,
Loader2: () => null,
Bot: () => null,
CircleDot: () => null,
XCircle: () => null,
GitMerge: () => null,
GitBranch: () => null,
}));
vi.mock("../../hooks/useAgentLogs", () => ({
useAgentLogs: vi.fn(() => ({ entries: [], loading: false, clear: vi.fn(), loadMore: vi.fn(async () => {}), hasMore: false, total: null, loadingMore: false })),
}));
// Mock usePluginUiSlots hook
const mockUsePluginUiSlots = vi.fn((_projectId?: string) => ({
slots: [] as any[],
getSlotsForId: vi.fn((_id: string) => [] as any[]),
loading: false,
error: null,
}));
vi.mock("../../hooks/usePluginUiSlots", () => ({
usePluginUiSlots: (projectId?: string) => mockUsePluginUiSlots(projectId),
}));
const mockConfirm = vi.fn();
vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: mockConfirm }),
}));
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
return {
id: "FN-099",
description: "Test task",
column: "in-progress" as Column,
dependencies: [],
prompt: "",
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
} as TaskDetail;
}
const noop = vi.fn();
const noopMove = vi.fn(async () => ({}) as Task);
const noopDelete = vi.fn(async () => ({}) as Task);
const noopMerge = vi.fn(async () => ({ merged: false }) as MergeResult);
const noopRetry = vi.fn(async () => ({}) as Task);
const noopOpenDetail = vi.fn();
function getCssRuleBlock(css: string, selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const ruleMatch = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
return ruleMatch?.[1] ?? "";
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function expectBaseRule(css: string, selector: string, declaration: string): void {
const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{[^}]*${escapeRegExp(declaration)}`);
expect(pattern.test(css)).toBe(true);
}
function readDashboardStylesSource(): string {
return loadAllAppCss();
}
describe("TaskDetailModal", () => {
beforeEach(() => {
mockConfirm.mockReset();
mockConfirm.mockResolvedValue(true);
clearAuthToken();
localStorage.removeItem("fn.authToken");
});
afterEach(() => {
clearAuthToken();
localStorage.removeItem("fn.authToken");
});
describe("provenance display", () => {
it.each([
["dashboard_ui", undefined, "Created via Dashboard"],
["agent_heartbeat", "agent-123", "Created by"],
] as const)("renders provenance text for %s", (sourceType, sourceAgentId, expectedText) => {
render(
<TaskDetailModal
task={makeTask({ sourceType, sourceAgentId })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText(new RegExp(expectedText))).toBeInTheDocument();
if (sourceType === "agent_heartbeat" && sourceAgentId) {
expect(screen.getByRole("button", { name: sourceAgentId })).toBeInTheDocument();
}
});
it("renders parent task link for refinement provenance", async () => {
render(
<TaskDetailModal
task={makeTask({ sourceType: "task_refine", sourceParentTaskId: "FN-001" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText(/Created via Refinement/)).toBeInTheDocument();
const link = screen.getByRole("button", { name: "FN-001" });
expect(link).toBeInTheDocument();
await userEvent.click(link);
await waitFor(() => {
expect(noopOpenDetail).toHaveBeenCalled();
});
});
it("renders issue URL for github import provenance", () => {
render(
<TaskDetailModal
task={makeTask({
sourceType: "github_import",
sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/42" },
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Created via GitHub Import (https://github.com/owner/repo/issues/42)")).toBeInTheDocument();
});
it.each(["unknown", undefined] as const)("omits provenance for %s source", (sourceType) => {
render(
<TaskDetailModal
task={makeTask({ sourceType })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText(/Created via/)).not.toBeInTheDocument();
});
});
it("styles detail-body scrollbar rules", () => {
const css = loadAllAppCss();
expectBaseRule(css, ".detail-body", "scrollbar-color: var(--border) transparent;");
expectBaseRule(css, ".detail-body", "scrollbar-width: thin;");
expectBaseRule(css, ".detail-body::-webkit-scrollbar", "width: 6px;");
expectBaseRule(css, ".detail-body::-webkit-scrollbar-track", "background: transparent;");
expectBaseRule(css, ".detail-body::-webkit-scrollbar-thumb", "background: var(--border);");
expectBaseRule(css, ".detail-body::-webkit-scrollbar-thumb:hover", "background: var(--text-muted);");
});
it("styles agent log viewer scroll container scrollbar rules", () => {
const css = loadAllAppCss();
expectBaseRule(css, ".agent-log-viewer", "overflow: hidden;");
expectBaseRule(css, ".agent-log-viewer-scroll", "scrollbar-color: var(--border) transparent;");
expectBaseRule(css, ".agent-log-viewer-scroll", "scrollbar-width: thin;");
expectBaseRule(css, ".agent-log-viewer-scroll::-webkit-scrollbar", "width: 6px;");
expectBaseRule(css, ".agent-log-viewer-scroll::-webkit-scrollbar-thumb", "background: var(--border);");
expectBaseRule(css, ".agent-log-model-header", "background: var(--bg-tertiary);");
});
it("renders markdown-body without detail-prompt class when prompt exists", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nSome **bold** text" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const markdownDiv = container.querySelector(".markdown-body");
expect(markdownDiv).toBeTruthy();
expect(markdownDiv!.classList.contains("detail-prompt")).toBe(false);
});
it("strips the leading heading from prompt and renders remaining markdown", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nSome **bold** text" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// The leading # heading should be stripped (modal has its own header)
expect(container.querySelector(".markdown-body h1")).toBeNull();
expect(container.querySelector("strong")?.textContent).toBe("bold");
});
it("renders (no prompt) with detail-prompt class when prompt is absent", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: undefined })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const fallback = screen.getByText("(no prompt)");
expect(fallback).toBeTruthy();
expect(fallback.classList.contains("detail-prompt")).toBe(true);
expect(fallback.classList.contains("markdown-body")).toBe(false);
});
it("does not render a PROMPT.md heading", () => {
render(
<TaskDetailModal
task={makeTask({ prompt: "# Some prompt content" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText("PROMPT.md")).toBeNull();
});
it("renders Comments tab", () => {
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Comments")).toBeTruthy();
});
it("appends daemon token query to attachment href/src URLs for direct browser loads", () => {
localStorage.setItem("fn.authToken", "daemon-token");
render(
<TaskDetailModal
task={makeTask({
attachments: [
{
filename: "screenshot.png",
originalName: "Screenshot",
mimeType: "image/png",
size: 1024,
createdAt: "2026-01-01T00:00:00Z",
},
],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const attachmentLink = screen.getByRole("link", { name: "Screenshot" });
const attachmentImage = screen.getByAltText("Screenshot");
expect(attachmentLink.getAttribute("href")).toBe(
"/api/tasks/FN-099/attachments/screenshot.png?fn_token=daemon-token",
);
expect(attachmentImage.getAttribute("src")).toBe(
"/api/tasks/FN-099/attachments/screenshot.png?fn_token=daemon-token",
);
});
it("leaves attachment href/src URLs unchanged when no daemon token is present", () => {
render(
<TaskDetailModal
task={makeTask({
attachments: [
{
filename: "screenshot.png",
originalName: "Screenshot",
mimeType: "image/png",
size: 1024,
createdAt: "2026-01-01T00:00:00Z",
},
],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const attachmentLink = screen.getByRole("link", { name: "Screenshot" });
const attachmentImage = screen.getByAltText("Screenshot");
expect(attachmentLink.getAttribute("href")).toBe("/api/tasks/FN-099/attachments/screenshot.png");
expect(attachmentImage.getAttribute("src")).toBe("/api/tasks/FN-099/attachments/screenshot.png");
});
it("renders Retry button when task status is 'failed' (in Actions dropdown)", () => {
render(
<TaskDetailModal
task={makeTask({ status: "failed" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={noopRetry}
addToast={noop}
/>,
);
// Open Actions dropdown to see Retry
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
expect(screen.getByRole("menuitem", { name: "Retry" })).toBeTruthy();
});
it("does NOT render Retry button when task status is not 'failed'", () => {
render(
<TaskDetailModal
task={makeTask({ status: "executing" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={noopRetry}
addToast={noop}
/>,
);
// No Retry should be visible in the Actions dropdown
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
expect(screen.queryByRole("menuitem", { name: "Retry" })).toBeNull();
});
it("does NOT render Retry button when onRetryTask is not provided", () => {
render(
<TaskDetailModal
task={makeTask({ status: "failed" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText("Retry")).toBeNull();
});
describe("retry action uniqueness for in-review failed tasks", () => {
it("shows exactly one Retry button when task is in-review AND failed (in Actions dropdown)", () => {
render(
<TaskDetailModal
task={makeTask({ column: "in-review", status: "failed" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={noopRetry}
addToast={noop}
/>,
);
// Open Actions dropdown and check for exactly one Retry
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
const retryButtons = screen.getAllByRole("menuitem", { name: "Retry" });
expect(retryButtons).toHaveLength(1);
});
it("shows exactly one Retry button when task is in-review AND stuck-killed (in Actions dropdown)", () => {
render(
<TaskDetailModal
task={makeTask({ column: "in-review", status: "stuck-killed" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={noopRetry}
addToast={noop}
/>,
);
// Open Actions dropdown and check for exactly one Retry
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
const retryButtons = screen.getAllByRole("menuitem", { name: "Retry" });
expect(retryButtons).toHaveLength(1);
});
it("shows Retry for a stranded planning triage task", () => {
render(
<TaskDetailModal
task={makeTask({ column: "triage", status: "planning", stuckKillCount: 6 })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={noopRetry}
addToast={noop}
/>,
);
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
const retryButtons = screen.getAllByRole("menuitem", { name: "Retry" });
expect(retryButtons).toHaveLength(1);
});
it("closes modal immediately when Retry is clicked (before API call)", async () => {
const onClose = vi.fn();
const onRetryTask = vi.fn(async () => ({}) as Task);
render(
<TaskDetailModal
task={makeTask({ column: "in-review", status: "failed" })}
onClose={onClose}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={onRetryTask}
addToast={noop}
/>,
);
// Open Actions dropdown and click Retry
const actionsBtn = screen.getByRole("button", { name: /actions/i });
await act(async () => {
fireEvent.click(actionsBtn);
});
const retryBtn = screen.getByRole("menuitem", { name: "Retry" });
await act(async () => {
fireEvent.click(retryBtn);
});
// Modal should close immediately (optimistic close before API call)
expect(onClose).toHaveBeenCalledTimes(1);
// onRetryTask should still be called with the correct task ID
expect(onRetryTask).toHaveBeenCalledWith("FN-099");
});
it("shows exactly one success toast when retry succeeds", async () => {
const onClose = vi.fn();
const onRetryTask = vi.fn(async () => ({}) as Task);
const addToast = vi.fn();
render(
<TaskDetailModal
task={makeTask({ column: "in-review", status: "failed" })}
onClose={onClose}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={onRetryTask}
addToast={addToast}
/>,
);
// Open Actions dropdown and click Retry
const actionsBtn = screen.getByRole("button", { name: /actions/i });
await act(async () => {
fireEvent.click(actionsBtn);
});
const retryBtn = screen.getByRole("menuitem", { name: "Retry" });
await act(async () => {
fireEvent.click(retryBtn);
});
// Wait for the promise to resolve
await act(async () => {});
// Only one toast — the success toast, no info toast
expect(addToast).toHaveBeenCalledTimes(1);
expect(addToast).toHaveBeenCalledWith("Retried FN-099", "success");
});
it("shows exactly one error toast when retry fails", async () => {
const onClose = vi.fn();
const onRetryTask = vi.fn(async () => {
throw new Error("Server error");
});
const addToast = vi.fn();
render(
<TaskDetailModal
task={makeTask({ column: "in-review", status: "failed" })}
onClose={onClose}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={onRetryTask}
addToast={addToast}
/>,
);
// Open Actions dropdown and click Retry
const actionsBtn = screen.getByRole("button", { name: /actions/i });
await act(async () => {
fireEvent.click(actionsBtn);
});
const retryBtn = screen.getByRole("menuitem", { name: "Retry" });
await act(async () => {
fireEvent.click(retryBtn);
});
// Wait for the promise to reject
await act(async () => {});
// Only one toast — the error toast
expect(addToast).toHaveBeenCalledTimes(1);
expect(addToast).toHaveBeenCalledWith("Server error", "error");
});
it("shows in-review split button with primary action and secondary move option", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ column: "in-review" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const moveBtn = screen.getByRole("button", { name: "Move to Todo" });
expect(moveBtn).toBeTruthy();
const chevronZone = container.querySelector(".detail-move-btn__arrow");
expect(chevronZone).toBeTruthy();
fireEvent.keyDown(moveBtn, { key: "ArrowDown" });
expect(screen.getByRole("menuitem", { name: "Back to In Progress" })).toBeTruthy();
expect(screen.queryByRole("menuitem", { name: "Move to Todo" })).toBeNull();
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
expect(screen.queryByRole("menuitem", { name: "Retry" })).toBeNull();
});
it("in-review failed task shows both Retry action and secondary move option", async () => {
render(
<TaskDetailModal
task={makeTask({ column: "in-review", status: "failed" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={noopRetry}
addToast={noop}
/>,
);
const actionsBtn = screen.getByRole("button", { name: /actions/i });
await act(async () => {
fireEvent.click(actionsBtn);
});
expect(screen.getByRole("menuitem", { name: "Retry" })).toBeTruthy();
expect(screen.getAllByRole("menuitem", { name: "Retry" })).toHaveLength(1);
const chevronZone = document.querySelector(".detail-move-btn__arrow");
await act(async () => {
fireEvent.click(chevronZone!);
});
expect(screen.getByRole("menuitem", { name: "Back to In Progress" })).toBeTruthy();
expect(screen.queryByRole("menuitem", { name: "Move to Todo" })).toBeNull();
});
it("split-button renders with chevron when multiple transitions exist", async () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ column: "in-progress" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const moveBtn = screen.getByRole("button", { name: "Move to In Review" });
expect(moveBtn).toBeTruthy();
const chevronZone = container.querySelector(".detail-move-btn__arrow");
expect(chevronZone).toBeTruthy();
await act(async () => {
fireEvent.click(chevronZone!);
});
expect(screen.getByRole("menuitem", { name: "Move to Todo" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Planning" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Done" })).toBeTruthy();
expect(screen.queryByRole("menuitem", { name: "Move to In Review" })).toBeNull();
});
it("split-button renders without chevron when only one transition", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ column: "triage" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByRole("button", { name: "Move to Todo" })).toBeTruthy();
expect(container.querySelector(".detail-move-btn__arrow")).toBeNull();
expect(container.querySelector(".detail-move-split-btn__divider")).toBeNull();
});
it("clicking main button executes primary transition immediately", async () => {
const onMoveTask = vi.fn().mockResolvedValue(undefined);
render(
<TaskDetailModal
task={makeTask({ column: "in-progress" })}
onClose={noop}
onMoveTask={onMoveTask}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Move to In Review" }));
});
expect(onMoveTask).toHaveBeenCalledWith("FN-099", "in-review");
expect(screen.queryByRole("menu")).toBeNull();
});
it("chevron dropdown includes only secondary transitions", async () => {
render(
<TaskDetailModal
task={makeTask({ column: "in-progress" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const moveBtn = screen.getByRole("button", { name: "Move to In Review" });
fireEvent.keyDown(moveBtn, { key: "ArrowDown" });
expect(screen.getByRole("menuitem", { name: "Move to Todo" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Planning" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Done" })).toBeTruthy();
expect(screen.queryByRole("menuitem", { name: "Move to In Review" })).toBeNull();
fireEvent.keyDown(screen.getByRole("menuitem", { name: "Move to Todo" }), { key: "Escape" });
expect(screen.queryByRole("menuitem", { name: "Move to Todo" })).toBeNull();
expect(document.activeElement).toBe(moveBtn);
});
});
it("shows description exactly once for a task without title", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
title: undefined,
description: "Fix the login bug",
prompt: "# KB-099\n\nFix the login bug\n",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// The heading "FN-099" should be stripped from the markdown
const markdownBody = container.querySelector(".markdown-body");
expect(markdownBody?.innerHTML).not.toContain("FN-099");
// Description appears in the markdown body
expect(markdownBody?.textContent).toContain("Fix the login bug");
// The detail header shows the ID (not duplicated as markdown heading)
expect(container.querySelector(".detail-id")?.textContent).toBe("FN-099");
// The h2 title shows description, not the task ID
const h2 = container.querySelector("h2.detail-title");
expect(h2?.textContent).toBe("Fix the login bug");
});
it("shows the title in <h2> when task.title is set", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
title: "Implement dark mode",
description: "Add dark mode toggle to the settings page",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const h2 = container.querySelector("h2.detail-title");
expect(h2?.textContent).toBe("Implement dark mode");
});
describe("description truncation", () => {
it("truncates description over 200 characters with Show more button", () => {
const longDescription = "A".repeat(250);
const { container } = render(
<TaskDetailModal
task={makeTask({
title: undefined,
description: longDescription,
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const h2 = container.querySelector("h2.detail-title");
expect(h2?.textContent).toBe("A".repeat(200) + "…");
const toggle = container.querySelector(".detail-description-toggle");
expect(toggle?.textContent).toBe("Show more");
});
it("expands full description when Show more is clicked", async () => {
const longDescription = "B".repeat(250);
const { container } = render(
<TaskDetailModal
task={makeTask({
title: undefined,
description: longDescription,
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const toggle = container.querySelector(".detail-description-toggle") as HTMLButtonElement;
await act(async () => {
fireEvent.click(toggle);
});
const h2 = container.querySelector("h2.detail-title");
expect(h2?.textContent).toBe("B".repeat(250));
expect(toggle.textContent).toBe("Show less");
});
it("collapses description when Show less is clicked", async () => {
const longDescription = "C".repeat(250);
const { container } = render(
<TaskDetailModal
task={makeTask({
title: undefined,
description: longDescription,
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// First expand
const toggle = container.querySelector(".detail-description-toggle") as HTMLButtonElement;
await act(async () => {
fireEvent.click(toggle);
});
// Then collapse
await act(async () => {
fireEvent.click(toggle);
});
const h2 = container.querySelector("h2.detail-title");
expect(h2?.textContent).toBe("C".repeat(200) + "…");
expect(toggle.textContent).toBe("Show more");
});
it("does not show toggle for description under 200 characters", () => {
const shortDescription = "Short description";
const { container } = render(
<TaskDetailModal
task={makeTask({
title: undefined,
description: shortDescription,
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const h2 = container.querySelector("h2.detail-title");
expect(h2?.textContent).toBe(shortDescription);
expect(container.querySelector(".detail-description-toggle")).toBeNull();
});
it("does not show toggle when title is present and short", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
title: "Short title",
description: "This is a longer description that would be truncated if it were shown as the main text",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const h2 = container.querySelector("h2.detail-title");
expect(h2?.textContent).toBe("Short title");
expect(container.querySelector(".detail-description-toggle")).toBeNull();
});
it("shows toggle when title exceeds 200 characters", () => {
const longTitle = "D".repeat(250);
const { container } = render(
<TaskDetailModal
task={makeTask({
title: longTitle,
description: "Short description",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const h2 = container.querySelector("h2.detail-title");
expect(h2?.textContent).toBe("D".repeat(200) + "…");
const toggle = container.querySelector(".detail-description-toggle");
expect(toggle?.textContent).toBe("Show more");
});
it("resets expanded state when task changes", async () => {
const longDescription1 = "E".repeat(250);
const longDescription2 = "F".repeat(250);
const { container, rerender } = render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
title: undefined,
description: longDescription1,
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Expand the first task
const toggle = container.querySelector(".detail-description-toggle") as HTMLButtonElement;
await act(async () => {
fireEvent.click(toggle);
});
// Verify expanded
const h2Before = container.querySelector("h2.detail-title");
expect(h2Before?.textContent).toBe("E".repeat(250));
// Change to a different task
rerender(
<TaskDetailModal
task={makeTask({
id: "FN-002",
title: undefined,
description: longDescription2,
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Should be collapsed again
const h2After = container.querySelector("h2.detail-title");
expect(h2After?.textContent).toBe("F".repeat(200) + "…");
});
});
it("always shows task.id in the detail-id badge regardless of title", () => {
// With title
const { container: withTitle } = render(
<TaskDetailModal
task={makeTask({ title: "Some title" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(withTitle.querySelector(".detail-id")?.textContent).toBe("FN-099");
// Without title
const { container: withoutTitle } = render(
<TaskDetailModal
task={makeTask({ title: undefined, description: "A description" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(withoutTitle.querySelector(".detail-id")?.textContent).toBe("FN-099");
});
describe("paste image upload", () => {
it("uploads an image when pasting clipboard image data", async () => {
const { uploadAttachment } = await import("../../api");
const mockUpload = vi.mocked(uploadAttachment);
const mockAttachment = {
filename: "abc123.png",
originalName: "image.png",
size: 1024,
mimeType: "image/png",
createdAt: "2026-01-01T00:00:00Z",
};
mockUpload.mockResolvedValueOnce(mockAttachment);
const addToast = vi.fn();
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
const imageFile = new File(["fake-image"], "image.png", { type: "image/png" });
const pasteEvent = new Event("paste", { bubbles: true }) as any;
pasteEvent.clipboardData = {
items: [
{
type: "image/png",
getAsFile: () => imageFile,
},
],
};
await act(async () => {
document.dispatchEvent(pasteEvent);
});
await waitFor(() => {
expect(mockUpload).toHaveBeenCalledWith("FN-099", imageFile, undefined);
expect(addToast).toHaveBeenCalledWith("Screenshot attached", "success");
});
});
it("does not intercept paste events without image data", async () => {
const { uploadAttachment } = await import("../../api");
const mockUpload = vi.mocked(uploadAttachment);
mockUpload.mockClear();
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const pasteEvent = new Event("paste", { bubbles: true }) as any;
pasteEvent.clipboardData = {
items: [
{
type: "text/plain",
getAsFile: () => null,
},
],
};
await act(async () => {
document.dispatchEvent(pasteEvent);
});
expect(mockUpload).not.toHaveBeenCalled();
});
it("shows uploading state during paste upload", async () => {
const { uploadAttachment } = await import("../../api");
const mockUpload = vi.mocked(uploadAttachment);
let resolveUpload!: (value: any) => void;
mockUpload.mockResolvedValueOnce(
new Promise((resolve) => {
resolveUpload = resolve;
}) as any,
);
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const imageFile = new File(["fake"], "shot.png", { type: "image/png" });
const pasteEvent = new Event("paste", { bubbles: true }) as any;
pasteEvent.clipboardData = {
items: [{ type: "image/png", getAsFile: () => imageFile }],
};
act(() => {
document.dispatchEvent(pasteEvent);
});
// While uploading, button should show "Uploading…"
await waitFor(() => {
expect(screen.getByText("Uploading…")).toBeTruthy();
});
await act(async () => {
resolveUpload({
filename: "x.png",
originalName: "shot.png",
size: 100,
mimeType: "image/png",
createdAt: "2026-01-01T00:00:00Z",
});
});
await waitFor(() => {
expect(screen.getByText("Attach Screenshot")).toBeTruthy();
});
});
});
describe("drag and drop image upload", () => {
it("uploads an image when dropped onto the modal", async () => {
const { uploadAttachment } = await import("../../api");
const mockUpload = vi.mocked(uploadAttachment);
const mockAttachment = {
filename: "drop123.png",
originalName: "dropped.png",
size: 2048,
mimeType: "image/png",
createdAt: "2026-01-01T00:00:00Z",
};
mockUpload.mockResolvedValueOnce(mockAttachment);
const addToast = vi.fn();
const { container } = render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
const modal = container.querySelector(".modal.modal-lg")!;
const imageFile = new File(["fake-image"], "dropped.png", { type: "image/png" });
await act(async () => {
fireEvent.drop(modal, {
dataTransfer: {
files: [imageFile],
},
});
});
await waitFor(() => {
expect(mockUpload).toHaveBeenCalledWith("FN-099", imageFile, undefined);
expect(addToast).toHaveBeenCalledWith("Screenshot attached", "success");
});
});
});
it("renders (no dependencies) when dependencies is empty", () => {
render(
<TaskDetailModal
task={makeTask({ dependencies: [] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("(no dependencies)")).toBeTruthy();
});
it("renders dependency list when dependencies exist", () => {
const allTasks: Task[] = [
{ id: "FN-001", title: "First dependency", description: "Desc 1", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
{ id: "FN-002", title: "Second dependency", description: "Desc 2", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
];
render(
<TaskDetailModal
task={makeTask({ dependencies: ["FN-001", "FN-002"] })}
tasks={allTasks}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Check that dependency IDs are rendered
const depIds = document.querySelectorAll(".detail-dep-id");
expect(depIds).toHaveLength(2);
expect(depIds[0].textContent).toBe("FN-001");
expect(depIds[1].textContent).toBe("FN-002");
// Check that dependency labels (titles) are rendered
const depLabels = document.querySelectorAll(".detail-dep-label");
expect(depLabels).toHaveLength(2);
expect(depLabels[0].textContent).toBe("First dependency");
expect(depLabels[1].textContent).toBe("Second dependency");
expect(screen.queryByText("(no dependencies)")).toBeNull();
});
it("can add a dependency via the dropdown", async () => {
const { updateTask } = await import("../../api");
const allTasks: Task[] = [
{ id: "FN-001", description: "Dep 1", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
{ id: "FN-099", description: "Self", column: "in-progress" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
];
render(
<TaskDetailModal
task={makeTask({ dependencies: [] })}
tasks={allTasks}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByText("Add Dependency"));
// Should show KB-001 in the dropdown but not KB-099 (self is excluded)
const dropdown = document.querySelector(".dep-dropdown")!;
expect(dropdown).toBeTruthy();
expect(dropdown.textContent).toContain("FN-001");
expect(dropdown.querySelectorAll(".dep-dropdown-item")).toHaveLength(1);
fireEvent.click(screen.getByText("FN-001"));
await waitFor(() => {
expect(updateTask).toHaveBeenCalledWith("FN-099", { dependencies: ["FN-001"] }, undefined);
});
});
it("can remove a dependency", async () => {
const { updateTask } = await import("../../api");
render(
<TaskDetailModal
task={makeTask({ dependencies: ["FN-001", "FN-002"] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const removeButtons = screen.getAllByTitle(/Remove dependency/);
fireEvent.click(removeButtons[0]); // Remove KB-001
await waitFor(() => {
expect(updateTask).toHaveBeenCalledWith("FN-099", { dependencies: ["FN-002"] }, undefined);
});
});
it("activity list does not have nested scroll constraints", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
log: [
{ timestamp: "2026-01-01T00:00:00Z", action: "Created task" },
{ timestamp: "2026-01-01T00:01:00Z", action: "Started work" },
{ timestamp: "2026-01-01T00:02:00Z", action: "Completed step 1" },
],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Click Logs tab — Activity is the default subview
fireEvent.click(screen.getByText("Logs"));
const activityList = container.querySelector(".detail-activity-list");
expect(activityList).toBeTruthy();
const style = (activityList as HTMLElement).style;
expect(style.overflowY).not.toBe("auto");
expect(style.maxHeight).toBe("");
});
it("renders dependency dropdown items sorted newest-first by createdAt", () => {
const allTasks: Task[] = [
{ id: "FN-001", description: "Oldest", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
{ id: "FN-003", description: "Newest", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-03-01T00:00:00Z", updatedAt: "2026-03-01T00:00:00Z" },
{ id: "FN-002", description: "Middle", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-02-01T00:00:00Z", updatedAt: "2026-02-01T00:00:00Z" },
{ id: "FN-099", description: "Self", column: "in-progress" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-03-15T00:00:00Z", updatedAt: "2026-03-15T00:00:00Z" },
];
render(
<TaskDetailModal
task={makeTask({ dependencies: [] })}
tasks={allTasks}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByText("Add Dependency"));
const items = document.querySelectorAll(".dep-dropdown-item");
expect(items).toHaveLength(3);
const ids = Array.from(items).map((el) => el.querySelector(".dep-dropdown-id")?.textContent);
expect(ids).toEqual(["FN-003", "FN-002", "FN-001"]);
});
it("renders tasks with identical createdAt sorted newest-ID-first in dependency dropdown", () => {
const allTasks: Task[] = [
{ id: "FN-001", description: "First", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
{ id: "FN-002", description: "Second", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
{ id: "FN-003", description: "Third", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
{ id: "FN-099", description: "Self", column: "in-progress" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
];
render(
<TaskDetailModal
task={makeTask({ dependencies: [] })}
tasks={allTasks}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByText("Add Dependency"));
const items = document.querySelectorAll(".dep-dropdown-item");
expect(items).toHaveLength(3);
const ids = Array.from(items).map((el) => el.querySelector(".dep-dropdown-id")?.textContent);
expect(ids).toEqual(["FN-003", "FN-002", "FN-001"]);
});
describe("tab toggle", () => {
it("defaults to the Definition tab", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Definition")).toBeTruthy();
expect(screen.getByText("Logs")).toBeTruthy();
// Activity and Agent Log are subviews inside the Logs tab, not top-level tabs
// They should NOT be visible on the Definition tab
expect(screen.queryByText("Activity")).toBeNull();
expect(screen.queryByText("Agent Log")).toBeNull();
// Definition content should be visible
expect(container.querySelector(".markdown-body")).toBeTruthy();
// Activity section should NOT be visible initially
expect(container.querySelector(".detail-activity")).toBeNull();
// Agent log viewer should not be visible
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeNull();
// After clicking Logs tab, the subview toggle buttons should appear
fireEvent.click(screen.getByText("Logs"));
const logSubviewToggle = container.querySelector(".log-subview-toggle");
expect(logSubviewToggle).toBeTruthy();
expect(logSubviewToggle!.textContent).toContain("Activity");
expect(logSubviewToggle!.textContent).toContain("Agent Log");
});
it("switches to Activity subview via Logs tab and shows activity feed", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
prompt: "# Hello\n\nContent",
log: [
{ timestamp: "2026-01-01T00:00:00Z", action: "Created task" },
],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Click Logs tab — Activity is the default subview
fireEvent.click(screen.getByText("Logs"));
// Activity section should be visible
expect(container.querySelector(".detail-activity")).toBeTruthy();
// Activity list should be visible
expect(container.querySelector(".detail-activity-list")).toBeTruthy();
// Definition content should be hidden
expect(container.querySelector(".markdown-body")).toBeNull();
});
it("Activity subview renders log entries correctly", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
log: [
{ timestamp: "2026-01-01T00:00:00Z", action: "Created task" },
{ timestamp: "2026-01-01T00:01:00Z", action: "Started work", outcome: "Success" },
{ timestamp: "2026-01-01T00:02:00Z", action: "Completed step 1" },
],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Click Logs tab — Activity is the default subview
fireEvent.click(screen.getByText("Logs"));
const activityList = container.querySelector(".detail-activity-list");
expect(activityList).toBeTruthy();
// Check log entries are rendered (in reverse order - newest first)
const logEntries = container.querySelectorAll(".detail-log-entry");
expect(logEntries).toHaveLength(3);
// Most recent entry should be first
expect(logEntries[0].textContent).toContain("Completed step 1");
expect(logEntries[1].textContent).toContain("Started work");
expect(logEntries[1].textContent).toContain("Success"); // outcome
expect(logEntries[2].textContent).toContain("Created task");
});
it("Activity subview keeps action/outcome rendering intact", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
log: [
{ timestamp: "2026-01-01T00:01:00Z", action: "Started work", outcome: "Step completed successfully" },
{ timestamp: "2026-01-01T00:00:00Z", action: "Created task" },
],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByText("Logs"));
const actions = container.querySelectorAll(".detail-log-action");
const outcomes = container.querySelectorAll(".detail-log-outcome");
expect(actions).toHaveLength(2);
expect(outcomes).toHaveLength(1);
expect(Array.from(actions).map((entry) => entry.textContent)).toEqual(["Created task", "Started work"]);
expect(outcomes[0].textContent).toBe("Step completed successfully");
});
it("Activity timeline CSS keeps action/outcome high-contrast and timestamp secondary", () => {
const stylesCssText = readDashboardStylesSource();
expect(stylesCssText).toContain(".detail-log-action");
const actionRule = getCssRuleBlock(stylesCssText, ".detail-log-action");
const outcomeRule = getCssRuleBlock(stylesCssText, ".detail-log-outcome");
const timestampRule = getCssRuleBlock(stylesCssText, ".detail-log-timestamp");
expect(actionRule).toContain("color: var(--text);");
expect(outcomeRule).toContain("color: var(--text);");
expect(outcomeRule).toContain("background: var(--surface);");
expect(timestampRule).toContain("color: var(--text-muted);");
expect(timestampRule).not.toContain("color: var(--text);");
});
it("Activity subview shows empty state when no logs", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ log: [] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Click Logs tab — Activity is the default subview
fireEvent.click(screen.getByText("Logs"));
// Activity section should be visible
expect(container.querySelector(".detail-activity")).toBeTruthy();
// Empty state should be shown
expect(container.querySelector(".detail-log-empty")).toBeTruthy();
expect(screen.getByText("(no activity)")).toBeTruthy();
// Activity list should NOT be present when empty
expect(container.querySelector(".detail-activity-list")).toBeNull();
});
it("can switch between all tabs and Logs subviews", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
prompt: "# Hello\n\nContent",
log: [{ timestamp: "2026-01-01T00:00:00Z", action: "Test" }],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Start on Definition tab
expect(container.querySelector(".markdown-body")).toBeTruthy();
expect(container.querySelector(".detail-activity")).toBeNull();
// Switch to Logs tab (Activity subview is default)
fireEvent.click(screen.getByText("Logs"));
expect(container.querySelector(".detail-activity")).toBeTruthy();
expect(container.querySelector(".markdown-body")).toBeNull();
// Switch to Agent Log subview within Logs tab
fireEvent.click(screen.getByText("Agent Log"));
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeTruthy();
expect(container.querySelector(".detail-activity")).toBeNull();
// Switch back to Activity subview within Logs tab
fireEvent.click(screen.getByText("Activity"));
expect(container.querySelector(".detail-activity")).toBeTruthy();
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeNull();
// Switch to Comments tab
fireEvent.click(screen.getByText("Comments"));
expect(screen.getByPlaceholderText(/Add a comment/)).toBeTruthy();
expect(container.querySelector(".detail-activity")).toBeNull();
// Switch back to Definition tab
fireEvent.click(screen.getByText("Definition"));
expect(container.querySelector(".markdown-body")).toBeTruthy();
expect(container.querySelector(".detail-activity")).toBeNull();
});
it("switches to Agent Log subview via Logs tab and back", async () => {
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
const mockUseAgentLogs = vi.mocked(useAgentLogs);
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Click Logs tab, then Agent Log subview
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
// Agent log viewer should appear
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeTruthy();
// Definition content should be hidden
expect(container.querySelector(".markdown-body")).toBeNull();
// Click Definition tab to go back
fireEvent.click(screen.getByText("Definition"));
// Definition content should reappear
expect(container.querySelector(".markdown-body")).toBeTruthy();
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeNull();
});
it("passes enabled=true to useAgentLogs only when Logs → Agent Log subview is active", async () => {
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
const mockUseAgentLogs = vi.mocked(useAgentLogs);
mockUseAgentLogs.mockClear();
const { rerender } = render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Default: Definition tab active → enabled should be false
const initialCall = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
expect(initialCall[1]).toBe(false);
// Switch to Logs tab (Activity subview is default) — enabled should still be false
fireEvent.click(screen.getByText("Logs"));
const afterLogsClick = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
expect(afterLogsClick[1]).toBe(false);
// Switch to Agent Log subview — enabled should become true
fireEvent.click(screen.getByText("Agent Log"));
const afterAgentLog = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
expect(afterAgentLog[1]).toBe(true);
});
it("switches to Comments tab", async () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Click Comments tab
fireEvent.click(screen.getByText("Comments"));
// Comments content should appear
const headings = screen.getAllByText("Comments");
expect(headings.length).toBeGreaterThanOrEqual(1);
expect(screen.getByPlaceholderText(/Add a comment/)).toBeTruthy();
// Definition content should be hidden
expect(container.querySelector(".markdown-body")).toBeNull();
});
it("shows correct top-level tabs including Logs", async () => {
const { container } = render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// For an in-progress task (no workflow steps, no merge commit), the
// top-level tabs are: Definition, Logs, Changes, Comments, Documents,
// Model, Workflow, Stats, Routing.
const tabTexts = ["Definition", "Logs", "Changes", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing"];
const tabs = screen.getAllByRole("button").filter((b) =>
tabTexts.includes(b.textContent || "")
);
expect(tabs.length).toBe(9);
expect(tabs[0].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Logs");
expect(tabs[2].textContent).toBe("Changes");
expect(tabs[3].textContent).toBe("Comments");
expect(tabs[4].textContent).toBe("Documents");
expect(tabs[5].textContent).toBe("Model");
expect(tabs[6].textContent).toBe("Workflow");
expect(tabs[7].textContent).toBe("Stats");
expect(tabs[8].textContent).toBe("Routing");
// Activity and Agent Log are NOT top-level tabs (they are subviews inside Logs)
expect(container.querySelectorAll(".detail-tab").length).toBe(9);
// Workflow tab should always appear even when no workflow steps are configured
expect(screen.getByText("Workflow")).toBeInTheDocument();
// Commits tab should NOT appear for non-done tasks
expect(screen.queryByText("Commits")).toBeNull();
});
});
describe("Agent Log full-height layout", () => {
it("applies detail-body--agent-log class when Logs → Agent Log subview is active", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Initially, detail-body should NOT have the agent-log modifier
expect(container.querySelector(".detail-body--agent-log")).toBeNull();
// Switch to Logs tab, then Agent Log subview
fireEvent.click(screen.getByText("Logs"));
expect(container.querySelector(".detail-body--agent-log")).toBeNull(); // Activity subview default
fireEvent.click(screen.getByText("Agent Log"));
// detail-body should now have the agent-log modifier class
expect(container.querySelector(".detail-body--agent-log")).toBeTruthy();
// Switch back to Definition tab
fireEvent.click(screen.getByText("Definition"));
// modifier class should be removed
expect(container.querySelector(".detail-body--agent-log")).toBeNull();
});
it("wraps AgentLogViewer in detail-section--agent-log class", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Switch to Logs tab, then Agent Log subview
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
// The section wrapping AgentLogViewer should have the full-height class
const section = container.querySelector(".detail-section--agent-log");
expect(section).toBeTruthy();
expect(section!.querySelector("[data-testid='agent-log-viewer']")).toBeTruthy();
});
it("does not apply detail-body--agent-log when editing", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ column: "triage", prompt: "# Hello\n\nContent" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Switch to Logs tab, then Agent Log subview first
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
expect(container.querySelector(".detail-body--agent-log")).toBeTruthy();
// Now enter edit mode via the pencil button in the header
const editBtn = screen.getByLabelText("Edit task");
fireEvent.click(editBtn);
// The detail-body--agent-log class should be removed while editing
expect(container.querySelector(".detail-body--agent-log")).toBeNull();
});
});
describe("Agent Log model resolution", () => {
// AgentLogViewer only renders the model header when entries.length > 0,
// so we mock useAgentLogs to return at least one entry.
const mockLogEntry = { timestamp: "2026-01-01T00:00:00Z", taskId: "FN-099", text: "hello", type: "text" as const };
async function setupModelTest(settingsOverrides: Record<string, any> = {}) {
const { fetchSettings } = await import("../../api");
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
...settingsOverrides,
} as any);
vi.mocked(useAgentLogs).mockReturnValue({
entries: [mockLogEntry],
loading: false,
clear: vi.fn(),
loadMore: vi.fn(async () => {}),
hasMore: false,
total: null,
loadingMore: false,
});
return render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
}
async function setupModelTestWithTask(taskOverrides: Partial<TaskDetail>, settingsOverrides: Record<string, any> = {}) {
const { fetchSettings } = await import("../../api");
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
...settingsOverrides,
} as any);
vi.mocked(useAgentLogs).mockReturnValue({
entries: [mockLogEntry],
loading: false,
clear: vi.fn(),
loadMore: vi.fn(async () => {}),
hasMore: false,
total: null,
loadingMore: false,
});
return render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent", ...taskOverrides })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
}
async function openAgentLogAndExpandModelDetails(container: HTMLElement) {
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
await waitFor(() => {
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
});
const expandButton = screen.getByTestId("agent-log-model-expand") as HTMLButtonElement;
if (expandButton.getAttribute("aria-expanded") !== "true") {
fireEvent.click(expandButton);
}
return container.querySelector("[data-testid='agent-log-model-header']") as HTMLElement;
}
it("shows resolved executor from settings when task has no explicit executor override", async () => {
const { container } = await setupModelTest({
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
const header = await openAgentLogAndExpandModelDetails(container);
// Validator should also fall back to the default
expect(header.textContent).toContain("anthropic/claude-sonnet-4-5");
});
it("shows the project default override before the global default", async () => {
const { container } = await setupModelTest({
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o",
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
const header = await openAgentLogAndExpandModelDetails(container);
const matches = header.textContent!.match(/openai\/gpt-4o/g);
expect(matches).toHaveLength(3);
expect(header.textContent).not.toContain("anthropic/claude-sonnet-4-5");
});
it("shows resolved validator from project validator settings when task has no validator override", async () => {
const { container } = await setupModelTest({
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
validatorProvider: "openai",
validatorModelId: "gpt-4o",
});
const header = await openAgentLogAndExpandModelDetails(container);
// Executor falls back to default
expect(header.textContent).toContain("anthropic/claude-sonnet-4-5");
// Validator uses the validator-specific setting
expect(header.textContent).toContain("openai/gpt-4o");
});
it("falls back to default settings for validator when no validator-specific setting exists", async () => {
const { container } = await setupModelTest({
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
// No validatorProvider or validatorModelId
});
const header = await openAgentLogAndExpandModelDetails(container);
// Count occurrences - should appear three times (once for executor, once for validator, once for planning)
const matches = header.textContent!.match(/anthropic\/claude-sonnet-4-5/g);
expect(matches).toHaveLength(3);
});
it("shows task executor override even when settings provide a default", async () => {
const { container } = await setupModelTestWithTask(
{ modelProvider: "openai", modelId: "gpt-4o" },
{ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" },
);
const header = await openAgentLogAndExpandModelDetails(container);
// Default model should not appear for executor
expect(header.textContent).toContain("openai/gpt-4o");
// Validator falls back to default
expect(header.textContent).toContain("anthropic/claude-sonnet-4-5");
});
it("shows task validator override even when settings provide a validator default", async () => {
const { container } = await setupModelTestWithTask(
{ validatorModelProvider: "google", validatorModelId: "gemini-pro" },
{ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o" },
);
const header = await openAgentLogAndExpandModelDetails(container);
// Executor falls back to default
expect(header.textContent).toContain("anthropic/claude-sonnet-4-5");
// Settings validator should not appear (task override wins)
expect(header.textContent).not.toContain("openai/gpt-4o");
});
it("shows 'Using default' for both when no models can be resolved", async () => {
const { container } = await setupModelTest({
// No defaultProvider/defaultModelId
});
const header = await openAgentLogAndExpandModelDetails(container);
expect(header.textContent).toContain("Using default");
// Should show "Using default" for executor, validator, and planning
const defaultBadges = header.querySelectorAll(".model-badge-default");
expect(defaultBadges).toHaveLength(3);
});
it("shows 'Using default' for both when settings fetch fails", async () => {
const { fetchSettings } = await import("../../api");
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
vi.mocked(fetchSettings).mockRejectedValueOnce(new Error("Network error"));
vi.mocked(useAgentLogs).mockReturnValue({
entries: [mockLogEntry],
loading: false,
clear: vi.fn(),
loadMore: vi.fn(async () => {}),
hasMore: false,
total: null,
loadingMore: false,
});
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Wait for the failed fetch to settle
const header = await openAgentLogAndExpandModelDetails(container);
expect(header.textContent).toContain("Using default");
const defaultBadges = header.querySelectorAll(".model-badge-default");
expect(defaultBadges).toHaveLength(3);
});
it("shows partial override: task executor with settings-based validator", async () => {
const { container } = await setupModelTestWithTask(
{
modelProvider: "google",
modelId: "gemini-pro",
// No validator override — should use settings validator
},
{
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
validatorProvider: "openai",
validatorModelId: "gpt-4o",
},
);
const header = await openAgentLogAndExpandModelDetails(container);
// Executor uses task override
expect(header.textContent).toContain("google/gemini-pro");
// Validator uses settings-specific validator
expect(header.textContent).toContain("openai/gpt-4o");
});
// Planning model resolution tests
describe("Planning model resolution", () => {
it("shows planning model from runtime triage log marker", async () => {
const { fetchSettings } = await import("../../api");
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
} as any);
vi.mocked(useAgentLogs).mockReturnValue({
entries: [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-099", text: "hello", type: "text" as const },
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-099", text: "Triage using model: google/gemini-pro", type: "text" as const, agent: "triage" },
],
loading: false,
clear: vi.fn(),
loadMore: vi.fn(async () => {}),
hasMore: false,
total: null,
loadingMore: false,
});
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const header = await openAgentLogAndExpandModelDetails(container);
// Planning should show the runtime triage marker, not settings default
expect(header.textContent).toContain("Planning:");
expect(header.textContent).toContain("google/gemini-pro");
// Executor/Validator should still show settings default
expect(header.textContent).toContain("anthropic/claude-sonnet-4-5");
});
it("shows planning model from settings planningProvider when no runtime marker", async () => {
const { fetchSettings } = await import("../../api");
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
planningProvider: "openai",
planningModelId: "gpt-4o",
} as any);
vi.mocked(useAgentLogs).mockReturnValue({
entries: [mockLogEntry],
loading: false,
clear: vi.fn(),
loadMore: vi.fn(async () => {}),
hasMore: false,
total: null,
loadingMore: false,
});
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const header = await openAgentLogAndExpandModelDetails(container);
// Planning should use planningProvider/planningModelId from settings
expect(header.textContent).toContain("Planning:");
expect(header.textContent).toContain("openai/gpt-4o");
// Executor/Validator should show default
expect(header.textContent).toContain("anthropic/claude-sonnet-4-5");
// Planning should NOT show the default
expect(header.textContent).toContain("openai/gpt-4o");
});
it("falls back to default settings for planning when no planning-specific setting exists", async () => {
const { container } = await setupModelTest({
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
const header = await openAgentLogAndExpandModelDetails(container);
expect(header.textContent).toContain("Planning:");
expect(header.textContent).toContain("anthropic/claude-sonnet-4-5");
// Planning falls back to default - same as executor/validator
const matches = header.textContent!.match(/anthropic\/claude-sonnet-4-5/g);
expect(matches).toHaveLength(3); // executor, validator, planning
});
it("shows 'Using default' for planning when no models can be resolved", async () => {
const { container } = await setupModelTest({
// No defaultProvider/defaultModelId
});
const header = await openAgentLogAndExpandModelDetails(container);
expect(header.textContent).toContain("Planning:");
const defaultBadges = header.querySelectorAll(".model-badge-default");
// 3 default badges: executor, validator, planning
expect(defaultBadges).toHaveLength(3);
});
it("per-task planning model override takes precedence over settings", async () => {
const { fetchSettings } = await import("../../api");
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
planningProvider: "openai",
planningModelId: "gpt-4o",
} as any);
vi.mocked(useAgentLogs).mockReturnValue({
entries: [mockLogEntry],
loading: false,
clear: vi.fn(),
loadMore: vi.fn(async () => {}),
hasMore: false,
total: null,
loadingMore: false,
});
const { container } = render(
<TaskDetailModal
task={makeTask({
prompt: "# Hello\n\nContent",
planningModelProvider: "google",
planningModelId: "gemini-2.5-pro",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const header = await openAgentLogAndExpandModelDetails(container);
// Per-task override should take precedence over settings
expect(header.textContent).toContain("Planning:");
expect(header.textContent).toContain("google/gemini-2.5-pro");
// Should NOT show the settings planning model
expect(header.textContent).not.toContain("openai/gpt-4o");
});
it("runtime triage marker takes precedence over planningProvider settings", async () => {
const { fetchSettings } = await import("../../api");
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
planningProvider: "openai",
planningModelId: "gpt-4o",
} as any);
vi.mocked(useAgentLogs).mockReturnValue({
entries: [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-099", text: "hello", type: "text" as const },
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-099", text: "Triage using model: google/gemini-pro", type: "text" as const, agent: "triage" },
],
loading: false,
clear: vi.fn(),
loadMore: vi.fn(async () => {}),
hasMore: false,
total: null,
loadingMore: false,
});
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const header = await openAgentLogAndExpandModelDetails(container);
// Runtime marker should win over planning settings
expect(header.textContent).toContain("google/gemini-pro");
// Should NOT show the planning settings model
expect(header.textContent).not.toContain("openai/gpt-4o");
});
});
});
describe("step progress", () => {
it("renders step progress section when steps exist", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
steps: [
{ name: "Step 1", status: "done" },
{ name: "Step 2", status: "in-progress" },
],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(container.querySelector(".detail-step-progress")).toBeTruthy();
expect(screen.getByText("Progress")).toBeTruthy();
});
it("shows '(no steps defined)' when steps array is empty", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ steps: [] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(container.querySelector(".detail-step-progress")).toBeTruthy();
expect(screen.getByText("(no steps defined)")).toBeTruthy();
});
it("renders correct number of segments matching step count", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
steps: [
{ name: "Step 1", status: "done" },
{ name: "Step 2", status: "in-progress" },
{ name: "Step 3", status: "pending" },
],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const segments = container.querySelectorAll(".step-progress-segment");
expect(segments).toHaveLength(3);
});
it("segments have correct status modifier classes", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
steps: [
{ name: "Step 1", status: "done" },
{ name: "Step 2", status: "in-progress" },
{ name: "Step 3", status: "pending" },
{ name: "Step 4", status: "skipped" },
],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const segments = container.querySelectorAll(".step-progress-segment");
expect(segments[0].classList.contains("step-progress-segment--done")).toBe(true);
expect(segments[1].classList.contains("step-progress-segment--in-progress")).toBe(true);
expect(segments[2].classList.contains("step-progress-segment--pending")).toBe(true);
expect(segments[3].classList.contains("step-progress-segment--skipped")).toBe(true);
});
it("segments have correct inline background colors based on status", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
steps: [
{ name: "Step 1", status: "done" },
{ name: "Step 2", status: "in-progress" },
{ name: "Step 3", status: "pending" },
{ name: "Step 4", status: "skipped" },
],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const segments = container.querySelectorAll(".step-progress-segment");
expect((segments[0] as HTMLElement).style.backgroundColor).toBe("var(--color-success, #3fb950)");
expect((segments[1] as HTMLElement).style.backgroundColor).toBe("var(--todo, #58a6ff)");
expect((segments[2] as HTMLElement).style.backgroundColor).toBe("var(--border, #30363d)");
expect((segments[3] as HTMLElement).style.backgroundColor).toBe("var(--text-dim, #484f58)");
});
it("displays singular completion label for one-step tasks", () => {
render(
<TaskDetailModal
task={makeTask({
steps: [{ name: "Step 1", status: "done" }],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("1/1 step")).toBeTruthy();
expect(screen.queryByText("1/1 steps")).toBeNull();
});
it("displays correct completion count", () => {
render(
<TaskDetailModal
task={makeTask({
steps: [
{ name: "Step 1", status: "done" },
{ name: "Step 2", status: "done" },
{ name: "Step 3", status: "pending" },
{ name: "Step 4", status: "in-progress" },
],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("2/4 steps")).toBeTruthy();
expect(screen.queryByText("2/4 step")).toBeNull();
});
it("has data-tooltip attribute with step name and status on each segment", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
steps: [
{ name: "Initialize project", status: "done" },
{ name: "Add tests", status: "in-progress" },
],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const segments = container.querySelectorAll(".step-progress-segment");
expect(segments[0].getAttribute("data-tooltip")).toBe("Initialize project (done)");
expect(segments[1].getAttribute("data-tooltip")).toBe("Add tests (in-progress)");
});
it("step progress only renders in Definition tab, not in Agent Log subview", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
prompt: "# Test",
steps: [
{ name: "Step 1", status: "done" },
],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Should be visible in Definition tab
expect(container.querySelector(".detail-step-progress")).toBeTruthy();
// Switch to Logs tab, then Agent Log subview
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
// Should not be visible in Agent Log subview
expect(container.querySelector(".detail-step-progress")).toBeNull();
});
it("step progress is hidden in Comments tab", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
prompt: "# Test",
steps: [
{ name: "Step 1", status: "done" },
],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Switch to Comments tab
fireEvent.click(screen.getByText("Comments"));
// Should not be visible in Comments tab
expect(container.querySelector(".detail-step-progress")).toBeNull();
});
});
describe("mobile responsive structure", () => {
it("renders responsive structural classes (modal-lg, overlay, spacer, tabs, detail-body)", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ column: "in-progress" as Column })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(container.querySelector(".modal.modal-lg")).toBeTruthy();
expect(container.querySelector(".modal-overlay.open")).toBeTruthy();
expect(container.querySelector(".modal-actions .modal-actions-spacer")).toBeTruthy();
expect(container.querySelector(".detail-body")).toBeTruthy();
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(9);
expect(tabs[0].classList.contains("detail-tab-active")).toBe(true);
expect(Array.from(tabs).slice(1).every((t) => !t.classList.contains("detail-tab-active"))).toBe(true);
// Responsive CSS controls sizing — no inline padding/fontSize/borderBottom leaks
expect((tabs[0] as HTMLElement).style.padding).toBe("");
expect((tabs[0] as HTMLElement).style.fontSize).toBe("");
expect((container.querySelector(".detail-tabs") as HTMLElement).style.borderBottom).toBe("");
});
it("modal-actions contains Delete and Pause buttons for non-done tasks (via Actions dropdown)", () => {
render(
<TaskDetailModal
task={makeTask({ column: "in-progress" as Column })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Actions are now in a dropdown - open it first
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
// Now the dropdown items should be visible
expect(screen.getByRole("menuitem", { name: "Delete" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Pause" })).toBeTruthy();
});
it("prompts for dependency-removal confirmation and retries delete with explicit flag", async () => {
const onDeleteTask = vi.fn();
const conflict = new Error("Cannot delete task FN-099: still referenced as a dependency by FN-100, FN-101.") as Error & {
status: number;
details: { code: string; dependentIds: string[] };
};
conflict.status = 409;
conflict.details = { code: "TASK_HAS_DEPENDENTS", dependentIds: ["FN-100", "FN-101"] };
onDeleteTask
.mockRejectedValueOnce(conflict)
.mockResolvedValueOnce({} as Task);
mockConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={onDeleteTask}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
await waitFor(() => {
expect(mockConfirm).toHaveBeenNthCalledWith(1, {
title: "Delete Task",
message: "Delete FN-099?",
danger: true,
});
expect(mockConfirm).toHaveBeenNthCalledWith(2, {
title: "Force Delete Task",
message: "FN-099 is a dependency of FN-100, FN-101.\n\nDelete anyway by removing these dependency references first?",
danger: true,
});
});
await waitFor(() => {
expect(onDeleteTask).toHaveBeenNthCalledWith(1, "FN-099");
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-099", { removeDependencyReferences: true });
expect(noop).toHaveBeenCalledWith("Deleted FN-099 after removing dependency references", "info");
});
});
it("does not retry delete when dependency-removal confirmation is canceled", async () => {
const onDeleteTask = vi.fn();
const conflict = new Error("Cannot delete task FN-099: still referenced as a dependency by FN-102.") as Error & {
status: number;
details: { code: string; dependentIds: string[] };
};
conflict.status = 409;
conflict.details = { code: "TASK_HAS_DEPENDENTS", dependentIds: ["FN-102"] };
onDeleteTask.mockRejectedValue(conflict);
mockConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false);
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={onDeleteTask}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledTimes(1);
});
});
it("shows error when dependency-removal retry fails", async () => {
const onDeleteTask = vi.fn();
const conflict = new Error("Cannot delete task FN-099: still referenced as a dependency by FN-103.") as Error & {
status: number;
details: { code: string; dependentIds: string[] };
};
conflict.status = 409;
conflict.details = { code: "TASK_HAS_DEPENDENTS", dependentIds: ["FN-103"] };
onDeleteTask
.mockRejectedValueOnce(conflict)
.mockRejectedValueOnce(new Error("Retry failed"));
mockConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={onDeleteTask}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
await waitFor(() => {
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-099", { removeDependencyReferences: true });
expect(noop).toHaveBeenCalledWith("Retry failed", "error");
});
});
it("in-review modal-actions contains Merge & Close and Back to In Progress buttons", () => {
render(
<TaskDetailModal
task={makeTask({ column: "in-review" as Column })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Merge & Close")).toBeTruthy();
// Back to In Progress is in secondary move options
fireEvent.click(document.querySelector(".detail-move-btn__arrow")!);
expect(screen.getByRole("menuitem", { name: "Back to In Progress" })).toBeTruthy();
});
it("shows PR automation waiting label instead of Merge & Close when awaiting PR checks", () => {
render(
<TaskDetailModal
task={makeTask({ column: "in-review" as Column, status: "awaiting-pr-checks", prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "Task",
headBranch: "fusion/fn-099",
baseBranch: "main",
commentCount: 0,
} })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const button = screen.getByText("Awaiting PR checks") as HTMLButtonElement;
expect(button.disabled).toBe(true);
expect(screen.queryByText("Merge & Close")).toBeNull();
});
it("shows Creating PR label while PR-first automation is creating a PR", () => {
render(
<TaskDetailModal
task={makeTask({ column: "in-review" as Column, status: "creating-pr" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const button = screen.getByText("Creating PR…") as HTMLButtonElement;
expect(button.disabled).toBe(true);
expect(screen.queryByText("Merge & Close")).toBeNull();
});
});
describe("dependency dropdown search", () => {
const searchTasks: Task[] = [
{ id: "FN-010", title: "Fix login bug", description: "Users cannot log in", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
{ id: "FN-020", title: "Add dark mode", description: "Theme support", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-02-01T00:00:00Z", updatedAt: "2026-02-01T00:00:00Z" },
{ id: "FN-030", title: "Refactor API", description: "Clean up endpoints", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-03-01T00:00:00Z", updatedAt: "2026-03-01T00:00:00Z" },
{ id: "FN-099", description: "Self", column: "in-progress" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-03-15T00:00:00Z", updatedAt: "2026-03-15T00:00:00Z" },
];
function renderWithSearch(taskOverrides: Partial<TaskDetail> = {}) {
return render(
<TaskDetailModal
task={makeTask(taskOverrides)}
tasks={searchTasks}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
}
it("shows search input when dropdown is opened", () => {
renderWithSearch();
fireEvent.click(screen.getByText("Add Dependency"));
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
expect(input).toBeTruthy();
expect(input.placeholder).toBe("Search tasks…");
});
it("filters tasks by search term", () => {
renderWithSearch();
fireEvent.click(screen.getByText("Add Dependency"));
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
fireEvent.change(input, { target: { value: "login" } });
const items = document.querySelectorAll(".dep-dropdown-item");
expect(items).toHaveLength(1);
expect(items[0].querySelector(".dep-dropdown-id")?.textContent).toBe("FN-010");
});
it("matches task ID case-insensitively", () => {
renderWithSearch();
fireEvent.click(screen.getByText("Add Dependency"));
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
fireEvent.change(input, { target: { value: "fn-020" } });
const items = document.querySelectorAll(".dep-dropdown-item");
expect(items).toHaveLength(1);
expect(items[0].querySelector(".dep-dropdown-id")?.textContent).toBe("FN-020");
});
it("matches task title", () => {
renderWithSearch();
fireEvent.click(screen.getByText("Add Dependency"));
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
fireEvent.change(input, { target: { value: "dark mode" } });
const items = document.querySelectorAll(".dep-dropdown-item");
expect(items).toHaveLength(1);
expect(items[0].querySelector(".dep-dropdown-id")?.textContent).toBe("FN-020");
});
it("shows empty state when search matches nothing", () => {
renderWithSearch();
fireEvent.click(screen.getByText("Add Dependency"));
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
fireEvent.change(input, { target: { value: "zzz-nonexistent" } });
const items = document.querySelectorAll(".dep-dropdown-item");
expect(items).toHaveLength(0);
expect(document.querySelector(".dep-dropdown-empty")?.textContent).toBe("No available tasks");
});
it("resets search when dropdown closes and reopens", () => {
renderWithSearch();
fireEvent.click(screen.getByText("Add Dependency"));
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
fireEvent.change(input, { target: { value: "login" } });
expect(input.value).toBe("login");
// Close by clicking again
fireEvent.click(screen.getByText("Add Dependency"));
expect(document.querySelector(".dep-dropdown")).toBeNull();
// Reopen
fireEvent.click(screen.getByText("Add Dependency"));
const newInput = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
expect(newInput.value).toBe("");
// All items visible again
expect(document.querySelectorAll(".dep-dropdown-item")).toHaveLength(3);
});
});
describe("clickable dependency links", () => {
it("renders dependency list items with clickable class and ID + label", () => {
// Provide tasks prop to enable title lookup
const allTasks: Task[] = [
{ id: "FN-001", title: "Fix login bug", description: "Login broken", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
{ id: "FN-002", title: "Add tests", description: "Test coverage", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
];
const { container } = render(
<TaskDetailModal
task={makeTask({ dependencies: ["FN-001", "FN-002"] })}
tasks={allTasks}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const depLinks = container.querySelectorAll(".detail-dep-link");
expect(depLinks).toHaveLength(2);
// Check detail-dep-id elements
const depIds = container.querySelectorAll(".detail-dep-id");
expect(depIds).toHaveLength(2);
expect(depIds[0].textContent).toBe("FN-001");
expect(depIds[1].textContent).toBe("FN-002");
// Check detail-dep-label elements
const depLabels = container.querySelectorAll(".detail-dep-label");
expect(depLabels).toHaveLength(2);
expect(depLabels[0].textContent).toBe("Fix login bug");
expect(depLabels[1].textContent).toBe("Add tests");
});
it("renders dependency label from description when title is not available", () => {
const allTasks: Task[] = [
{ id: "FN-001", description: "Login is broken", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
];
const { container } = render(
<TaskDetailModal
task={makeTask({ dependencies: ["FN-001"] })}
tasks={allTasks}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const depLabels = container.querySelectorAll(".detail-dep-label");
expect(depLabels).toHaveLength(1);
expect(depLabels[0].textContent).toBe("Login is broken");
});
it("renders dependency ID as label when no title or description available", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ dependencies: ["FN-001"] })}
// No tasks prop - dependency not found
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const depLabels = container.querySelectorAll(".detail-dep-label");
expect(depLabels).toHaveLength(1);
// Should fall back to the ID itself
expect(depLabels[0].textContent).toBe("FN-001");
});
it("truncates long dependency labels at 40 characters", () => {
// Title is exactly 50 chars, should be truncated to 40 with ellipsis
const longTitle = "This is a very long task title that exceeds the limit";
const allTasks: Task[] = [
{ id: "FN-001", title: longTitle, description: "Short desc", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
];
const { container } = render(
<TaskDetailModal
task={makeTask({ dependencies: ["FN-001"] })}
tasks={allTasks}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const depLabels = container.querySelectorAll(".detail-dep-label");
expect(depLabels).toHaveLength(1);
// Title is 50 chars, should be truncated to 40 with ellipsis
// "This is a very long task title that exceed" + "…" = 41 chars
expect(depLabels[0].textContent!.length).toBe(41); // 40 chars + ellipsis
expect(depLabels[0].textContent).toContain("…");
});
it("preserves full text in title attribute for truncated labels", () => {
const allTasks: Task[] = [
{ id: "FN-001", title: "Very long title that gets truncated in the UI but should show full text on hover", description: "Desc", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
];
const { container } = render(
<TaskDetailModal
task={makeTask({ dependencies: ["FN-001"] })}
tasks={allTasks}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const depLink = container.querySelector(".detail-dep-link")!;
// The title attribute should contain the full ID for context
expect(depLink.getAttribute("title")).toContain("FN-001");
});
it("calls fetchTaskDetail and onOpenDetail when clicking a dependency", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
const mockDetail: TaskDetail = {
...makeTask({ id: "FN-001", description: "Dep 1" }),
prompt: "",
attachments: [],
};
mockFetch.mockResolvedValueOnce(mockDetail);
const onOpenDetail = vi.fn();
const { container } = render(
<TaskDetailModal
task={makeTask({ dependencies: ["FN-001"] })}
onOpenDetail={onOpenDetail}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
const depLink = container.querySelector(".detail-dep-link")!;
fireEvent.click(depLink);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("FN-001", undefined);
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
});
});
it("shows error toast when dependency fetch fails", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
mockFetch.mockRejectedValueOnce(new Error("Task not found"));
const onOpenDetail = vi.fn();
const addToast = vi.fn();
const { container } = render(
<TaskDetailModal
task={makeTask({ dependencies: ["FN-001"] })}
onOpenDetail={onOpenDetail}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={addToast}
/>,
);
const depLink = container.querySelector(".detail-dep-link")!;
fireEvent.click(depLink);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to load dependency FN-001", "error");
});
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("remove button click does not trigger dependency click", async () => {
const { updateTask } = await import("../../api");
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
mockFetch.mockRejectedValueOnce(new Error("Should not be called"));
const onOpenDetail = vi.fn();
render(
<TaskDetailModal
task={makeTask({ dependencies: ["FN-001"] })}
onOpenDetail={onOpenDetail}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
const removeButton = screen.getByTitle(/Remove dependency/);
fireEvent.click(removeButton);
// onOpenDetail should not be called when clicking remove
expect(onOpenDetail).not.toHaveBeenCalled();
// updateTask should be called to remove the dependency
await waitFor(() => {
expect(updateTask).toHaveBeenCalledWith("FN-099", { dependencies: [] }, undefined);
});
});
});
describe("Definition tab edit mode", () => {
it("shows Edit button in Definition tab", () => {
render(
<TaskDetailModal
task={makeTask({ prompt: "# Test\n\nSpec content." })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Edit")).toBeTruthy();
});
it("clicking Edit shows textarea with current prompt content", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Test\n\nSpec content." })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Initially showing markdown view
expect(container.querySelector(".markdown-body")).toBeTruthy();
// Click Edit button
fireEvent.click(screen.getByText("Edit"));
// Should show spec edit textarea (query by class for specificity)
const textarea = container.querySelector(".spec-editor-textarea") as HTMLTextAreaElement;
expect(textarea).toBeTruthy();
expect(textarea.value).toBe("# Test\n\nSpec content.");
});
it("clicking Cancel returns to view mode without saving", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Test Task\n\nTest specification." })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByText("Edit"));
const textarea = container.querySelector(".spec-editor-textarea") as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: "Modified content" } });
// Click Cancel
fireEvent.click(screen.getByText("Cancel"));
// Should show markdown view with original content
expect(container.querySelector(".markdown-body")).toBeTruthy();
expect(container.querySelector(".spec-editor-textarea")).toBeNull();
});
it("saving updates the task and returns to view mode", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-099" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-099", prompt: "# Original" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByText("Edit"));
const textarea = container.querySelector(".spec-editor-textarea") as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: "# Updated" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-099", { prompt: "# Updated" }, undefined);
});
// Should return to view mode
expect(container.querySelector(".markdown-body")).toBeTruthy();
});
it("AI revision feedback section appears in edit mode", () => {
render(
<TaskDetailModal
task={makeTask({ prompt: "# Test" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByText("Edit"));
expect(screen.getByText("Ask AI to Revise")).toBeTruthy();
expect(screen.getByPlaceholderText(/e.g., 'Add more details/)).toBeTruthy();
expect(screen.getByText("Request AI Revision")).toBeTruthy();
});
it("requesting AI revision works and closes modal", async () => {
const { requestSpecRevision } = await import("../../api");
vi.mocked(requestSpecRevision).mockResolvedValueOnce({} as any);
const onClose = vi.fn();
const addToast = vi.fn();
render(
<TaskDetailModal
task={makeTask({ id: "FN-099", column: "todo", prompt: "# Test" })}
onClose={onClose}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
fireEvent.click(screen.getByText("Edit"));
const feedbackInput = screen.getByPlaceholderText(/e.g., 'Add more details/);
fireEvent.change(feedbackInput, { target: { value: "Please add more error handling details" } });
fireEvent.click(screen.getByText("Request AI Revision"));
await waitFor(() => {
expect(requestSpecRevision).toHaveBeenCalledWith("FN-099", "Please add more error handling details", undefined);
expect(addToast).toHaveBeenCalledWith("AI revision requested. Task moved to planning.", "success");
expect(onClose).toHaveBeenCalled();
});
});
it("shows all tabs in correct order for in-progress task", () => {
const { container } = render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// In-progress tasks show exactly 9 tabs:
// Definition, Logs, Changes, Comments, Documents, Model, Workflow, Stats, Routing
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(9);
expect(tabs[0].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Logs");
expect(tabs[2].textContent).toBe("Changes");
expect(tabs[3].textContent).toBe("Comments");
expect(tabs[4].textContent).toBe("Documents");
expect(tabs[5].textContent).toBe("Model");
expect(tabs[6].textContent).toBe("Workflow");
expect(tabs[7].textContent).toBe("Stats");
expect(tabs[8].textContent).toBe("Routing");
// Commits tab should NOT be present for non-done tasks
expect(screen.queryByText("Commits")).toBeNull();
});
it("shows Workflow tab in correct position when enabledWorkflowSteps is non-empty", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ enabledWorkflowSteps: ["WS-001"] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// In-progress task with workflow steps: 9 tabs (Workflow after Model, Stats then Routing)
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(9);
expect(tabs[0].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Logs");
expect(tabs[2].textContent).toBe("Changes");
expect(tabs[3].textContent).toBe("Comments");
expect(tabs[4].textContent).toBe("Documents");
expect(tabs[5].textContent).toBe("Model");
expect(tabs[6].textContent).toBe("Workflow");
expect(tabs[7].textContent).toBe("Stats");
expect(tabs[8].textContent).toBe("Routing");
});
it("does NOT show Commits tab for done task with mergeDetails.commitSha (changes merged into Changes tab)", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
column: "done",
mergeDetails: { commitSha: "abc1234567890", filesChanged: 3 },
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Done task with commit SHA: Definition, Logs, Changes, Comments, Documents, Model, Workflow, Stats, Routing (9 tabs, no Commits)
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(9);
expect(tabs[0].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Logs");
expect(tabs[2].textContent).toBe("Changes");
expect(tabs[3].textContent).toBe("Comments");
expect(tabs[4].textContent).toBe("Documents");
expect(tabs[5].textContent).toBe("Model");
expect(tabs[6].textContent).toBe("Workflow");
expect(tabs[7].textContent).toBe("Stats");
expect(tabs[8].textContent).toBe("Routing");
// Commits tab should NOT be present
expect(screen.queryByText("Commits")).toBeNull();
});
it("shows 9 tabs for done task with workflow steps and commit SHA (Commits merged into Changes)", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
column: "done",
mergeDetails: { commitSha: "abc1234567890", filesChanged: 3 },
enabledWorkflowSteps: ["WS-001"],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Done task with workflow steps and commit SHA: 9 tabs (no Commits)
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(9);
expect(tabs[0].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Logs");
expect(tabs[2].textContent).toBe("Changes");
expect(tabs[3].textContent).toBe("Comments");
expect(tabs[4].textContent).toBe("Documents");
expect(tabs[5].textContent).toBe("Model");
expect(tabs[6].textContent).toBe("Workflow");
expect(tabs[7].textContent).toBe("Stats");
expect(tabs[8].textContent).toBe("Routing");
// Commits tab should NOT be present
expect(screen.queryByText("Commits")).toBeNull();
});
it("does NOT show Changes tab for triage/todo tasks", () => {
const { container: triageContainer } = render(
<TaskDetailModal
task={makeTask({ column: "triage" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const triageTabs = triageContainer.querySelectorAll(".detail-tab");
expect(triageTabs.length).toBe(8); // Definition, Logs, Comments, Documents, Model, Workflow, Stats, Routing
expect(Array.from(triageTabs).map(t => t.textContent)).toEqual([
"Definition", "Logs", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing",
]);
const { container: todoContainer } = render(
<TaskDetailModal
task={makeTask({ column: "todo" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const todoTabs = todoContainer.querySelectorAll(".detail-tab");
expect(todoTabs.length).toBe(8); // Definition, Logs, Comments, Documents, Model, Workflow, Stats, Routing
expect(Array.from(todoTabs).map(t => t.textContent)).toEqual([
"Definition", "Logs", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing",
]);
});
it("shows empty state and Edit button when no prompt", () => {
render(
<TaskDetailModal
task={makeTask({ prompt: "" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("(no prompt)")).toBeTruthy();
expect(screen.getByText("Edit")).toBeTruthy();
});
});
describe("Plan Approval UI", () => {
it("shows Approve Plan and Reject Plan buttons for awaiting-approval tasks in triage", () => {
render(
<TaskDetailModal
task={makeTask({
column: "triage",
status: "awaiting-approval",
prompt: "# Task Spec",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Approve Plan")).toBeTruthy();
expect(screen.getByText("Reject Plan")).toBeTruthy();
});
it("does not show approval buttons when task is not in triage", () => {
render(
<TaskDetailModal
task={makeTask({
column: "todo",
status: "awaiting-approval",
prompt: "# Task Spec",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText("Approve Plan")).toBeNull();
expect(screen.queryByText("Reject Plan")).toBeNull();
});
it("does not show approval buttons when task does not have awaiting-approval status", () => {
render(
<TaskDetailModal
task={makeTask({
column: "triage",
status: "planning",
prompt: "# Task Spec",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText("Approve Plan")).toBeNull();
expect(screen.queryByText("Reject Plan")).toBeNull();
});
it("does not show approval buttons when task has no prompt", () => {
render(
<TaskDetailModal
task={makeTask({
column: "triage",
status: "awaiting-approval",
prompt: "",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText("Approve Plan")).toBeNull();
expect(screen.queryByText("Reject Plan")).toBeNull();
});
it("calls approvePlan API and shows success toast when Approve Plan is clicked", async () => {
const { approvePlan } = await import("../../api");
const mockApprovePlan = vi.mocked(approvePlan);
const addToast = vi.fn();
const onClose = vi.fn();
render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "triage",
status: "awaiting-approval",
prompt: "# Task Spec",
})}
onClose={onClose}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
fireEvent.click(screen.getByText("Approve Plan"));
await waitFor(() => {
expect(mockApprovePlan).toHaveBeenCalledWith("FN-001", undefined);
});
expect(addToast).toHaveBeenCalledWith("Plan approved — FN-001 moved to Todo", "success");
expect(onClose).toHaveBeenCalled();
});
it("calls rejectPlan API and shows success toast when Reject Plan is confirmed", async () => {
const { rejectPlan } = await import("../../api");
const mockRejectPlan = vi.mocked(rejectPlan);
const addToast = vi.fn();
const onClose = vi.fn();
// Mock confirm to return true
mockConfirm.mockResolvedValue(true);
render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "triage",
status: "awaiting-approval",
prompt: "# Task Spec",
})}
onClose={onClose}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
fireEvent.click(screen.getByText("Reject Plan"));
expect(mockConfirm).toHaveBeenCalledWith({
title: "Reject Plan",
message: "Reject this plan? The specification will be discarded and regenerated.",
danger: true,
});
await waitFor(() => {
expect(mockRejectPlan).toHaveBeenCalledWith("FN-001", undefined);
});
expect(addToast).toHaveBeenCalledWith(
"Plan rejected — FN-001 returned to Planning for replanning",
"info"
);
expect(onClose).toHaveBeenCalled();
});
it("does not call rejectPlan API when Reject Plan is cancelled", async () => {
const { rejectPlan } = await import("../../api");
const mockRejectPlan = vi.mocked(rejectPlan);
mockRejectPlan.mockClear(); // Clear any previous calls
const addToast = vi.fn();
// Mock confirm to return false
mockConfirm.mockResolvedValue(false);
render(
<TaskDetailModal
task={makeTask({
column: "triage",
status: "awaiting-approval",
prompt: "# Task Spec",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
fireEvent.click(screen.getByText("Reject Plan"));
expect(mockConfirm).toHaveBeenCalled();
expect(mockRejectPlan).not.toHaveBeenCalled();
expect(addToast).not.toHaveBeenCalled();
});
it("shows error toast when approvePlan fails", async () => {
const { approvePlan } = await import("../../api");
const mockApprovePlan = vi.mocked(approvePlan);
mockApprovePlan.mockRejectedValueOnce(new Error("Network error"));
const addToast = vi.fn();
render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "triage",
status: "awaiting-approval",
prompt: "# Task Spec",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
fireEvent.click(screen.getByText("Approve Plan"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Network error", "error");
});
});
it("shows error toast when rejectPlan fails", async () => {
const { rejectPlan } = await import("../../api");
const mockRejectPlan = vi.mocked(rejectPlan);
mockRejectPlan.mockRejectedValueOnce(new Error("Server error"));
const addToast = vi.fn();
// Mock confirm to return true
mockConfirm.mockResolvedValue(true);
render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "triage",
status: "awaiting-approval",
prompt: "# Task Spec",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
fireEvent.click(screen.getByText("Reject Plan"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Server error", "error");
});
});
});
describe("Duplicate button", () => {
it("renders Duplicate button in modal actions when onDuplicateTask is provided (in Actions dropdown)", () => {
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onDuplicateTask={vi.fn()}
addToast={noop}
/>,
);
// Open Actions dropdown to see Duplicate
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
expect(screen.getByRole("menuitem", { name: "Duplicate" })).toBeTruthy();
});
it("does NOT render Duplicate button when onDuplicateTask is not provided", () => {
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Open Actions dropdown - Duplicate should not be there
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
expect(screen.queryByRole("menuitem", { name: "Duplicate" })).toBeNull();
});
it("clicking Duplicate shows confirmation dialog", () => {
mockConfirm.mockResolvedValue(false);
render(
<TaskDetailModal
task={makeTask({ id: "FN-001" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onDuplicateTask={vi.fn()}
addToast={noop}
/>,
);
// Open Actions dropdown first
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
fireEvent.click(screen.getByRole("menuitem", { name: "Duplicate" }));
expect(mockConfirm).toHaveBeenCalledWith({
title: "Duplicate Task",
message: "Duplicate FN-001? This will create a new task in Triage with the same description and prompt.",
});
});
it("confirming duplicate calls onDuplicateTask and closes modal", async () => {
mockConfirm.mockResolvedValue(true);
const mockDuplicate = vi.fn().mockResolvedValue({ id: "FN-002" } as Task);
const onClose = vi.fn();
render(
<TaskDetailModal
task={makeTask({ id: "FN-001" })}
onClose={onClose}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onDuplicateTask={mockDuplicate}
addToast={noop}
/>,
);
// Open Actions dropdown first
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
fireEvent.click(screen.getByRole("menuitem", { name: "Duplicate" }));
await waitFor(() => {
expect(mockDuplicate).toHaveBeenCalledWith("FN-001");
expect(onClose).toHaveBeenCalled();
});
});
it("successful duplicate shows success toast with new task ID", async () => {
mockConfirm.mockResolvedValue(true);
const mockDuplicate = vi.fn().mockResolvedValue({ id: "FN-002" } as Task);
const addToast = vi.fn();
render(
<TaskDetailModal
task={makeTask({ id: "FN-001" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onDuplicateTask={mockDuplicate}
addToast={addToast}
/>,
);
// Open Actions dropdown first
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
fireEvent.click(screen.getByRole("menuitem", { name: "Duplicate" }));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Duplicated FN-001 → FN-002", "success");
});
});
it("cancelling confirmation does not call onDuplicateTask", () => {
mockConfirm.mockResolvedValue(false);
const mockDuplicate = vi.fn().mockResolvedValue({ id: "FN-002" } as Task);
render(
<TaskDetailModal
task={makeTask({ id: "FN-001" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onDuplicateTask={mockDuplicate}
addToast={noop}
/>,
);
// Open Actions dropdown first
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
fireEvent.click(screen.getByRole("menuitem", { name: "Duplicate" }));
expect(mockDuplicate).not.toHaveBeenCalled();
});
it("shows error toast when duplicate fails", async () => {
mockConfirm.mockResolvedValue(true);
const mockDuplicate = vi.fn().mockRejectedValue(new Error("Duplicate failed"));
const addToast = vi.fn();
render(
<TaskDetailModal
task={makeTask({ id: "FN-001" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onDuplicateTask={mockDuplicate}
addToast={addToast}
/>,
);
// Open Actions dropdown first
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
fireEvent.click(screen.getByRole("menuitem", { name: "Duplicate" }));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Duplicate failed", "error");
});
});
});
describe("Refinement button", () => {
it.each<[Column, boolean]>([
["done", true],
["in-review", true],
["todo", false],
["in-progress", false],
])("Refine action visibility in column=%s is %s", (column, shouldShow) => {
render(
<TaskDetailModal
task={makeTask({ column })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
const item = screen.queryByRole("menuitem", { name: "Refine" });
if (shouldShow) expect(item).toBeTruthy();
else expect(item).toBeNull();
});
it("does NOT render Refine button for 'triage' column tasks (no Actions dropdown)", () => {
render(
<TaskDetailModal
task={makeTask({ column: "triage" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText("Refine")).toBeNull();
});
it("renders Actions dropdown for a paused triage task", () => {
render(
<TaskDetailModal
task={makeTask({ column: "triage", paused: true })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByRole("button", { name: /actions/i })).toBeTruthy();
});
it("renders Unpause button for a paused triage task", () => {
render(
<TaskDetailModal
task={makeTask({ column: "triage", paused: true })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
expect(screen.getByRole("menuitem", { name: "Unpause" })).toBeTruthy();
});
it("does NOT render Actions dropdown for a non-paused, non-awaiting-approval, non-retryable triage task", () => {
render(
<TaskDetailModal
task={makeTask({ column: "triage", paused: false, status: "todo" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByRole("button", { name: /actions/i })).toBeNull();
});
it("clicking Refine opens the refinement modal", () => {
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "done" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Open Actions dropdown first
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
expect(screen.getByText("Refine", { selector: "h3" })).toBeTruthy();
expect(screen.getByPlaceholderText("Enter your feedback here...")).toBeTruthy();
});
it("shows character counter in refinement modal", () => {
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "done" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Open Actions dropdown first
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
expect(screen.getByText("0/2000 characters")).toBeTruthy();
});
it("character counter updates when typing feedback", async () => {
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "done" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Open Actions dropdown first
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
const textarea = screen.getByPlaceholderText("Enter your feedback here...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "Need to fix the error handling" } });
});
expect(screen.getByText("30/2000 characters")).toBeTruthy();
});
it("submit button is disabled when feedback is empty", () => {
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "done" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Open Actions dropdown first
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
const submitButton = screen.getByText("Create Refinement Task");
expect(submitButton.hasAttribute("disabled")).toBe(true);
});
it("submit button is enabled when feedback is entered", async () => {
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "done" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Open Actions dropdown first
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
const textarea = screen.getByPlaceholderText("Enter your feedback here...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "Need to fix error handling" } });
});
const submitButton = screen.getByText("Create Refinement Task");
expect(submitButton.hasAttribute("disabled")).toBe(false);
});
it("clicking Cancel closes the refinement modal", () => {
const onClose = vi.fn();
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "done" })}
onClose={onClose}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Open Actions dropdown first
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
fireEvent.click(screen.getByText("Cancel"));
// Modal should be closed, but detail modal stays open (onClose not called)
expect(screen.queryByText("Refine", { selector: "h3" })).toBeNull();
expect(onClose).not.toHaveBeenCalled();
});
it("shows error toast when submitting empty feedback", async () => {
const addToast = vi.fn();
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "done" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
// Open Actions dropdown first
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
// Try to submit with empty text (manually trigger submit since button is disabled)
const { refineTask } = await import("../../api");
// Should not call API, instead show error toast
expect(refineTask).not.toHaveBeenCalled();
});
it("calls refineTask and closes modal on successful submission", async () => {
const { refineTask } = await import("../../api");
vi.mocked(refineTask).mockResolvedValue({ id: "FN-002", column: "triage" } as Task);
const onClose = vi.fn();
const addToast = vi.fn();
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "done" })}
onClose={onClose}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
// Open Actions dropdown first
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
const textarea = screen.getByPlaceholderText("Enter your feedback here...");
fireEvent.change(textarea, { target: { value: "Need to add more tests" } });
fireEvent.click(screen.getByText("Create Refinement Task"));
await waitFor(() => {
expect(refineTask).toHaveBeenCalledWith("FN-001", "Need to add more tests", undefined);
expect(addToast).toHaveBeenCalledWith("Refinement task created: FN-002", "success");
expect(onClose).toHaveBeenCalled();
});
});
it("shows error toast when refineTask fails", async () => {
const { refineTask } = await import("../../api");
vi.mocked(refineTask).mockRejectedValue(new Error("Task must be in 'done' or 'in-review' column"));
const addToast = vi.fn();
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "done" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
// Open Actions dropdown first
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
// Click Refine from the dropdown
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
const textarea = screen.getByPlaceholderText("Enter your feedback here...");
fireEvent.change(textarea, { target: { value: "Need to add more tests" } });
fireEvent.click(screen.getByText("Create Refinement Task"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Task must be in 'done' or 'in-review' column", "error");
});
});
it("renders submit button inside the input group adjacent to textarea", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "done" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Open Actions dropdown first
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
// Click Refine from the dropdown
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
// The submit button should be inside .detail-refine-input-group (the input area)
const inputGroup = container.querySelector(".detail-refine-input-group");
expect(inputGroup).toBeTruthy();
const submitButton = inputGroup!.querySelector("button.btn-primary");
expect(submitButton).toBeTruthy();
expect(submitButton!.textContent).toBe("Create Refinement Task");
// The submit button should NOT be in the footer .modal-actions
const modalActions = container.querySelector(".detail-refine-modal .modal-actions");
expect(modalActions).toBeTruthy();
expect(modalActions!.querySelector("button.btn-primary")).toBeNull();
});
it("submit button in input group follows the same disabled/enabled rules", async () => {
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "done" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Open Actions dropdown first
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
// Click Refine from the dropdown
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
// Submit button starts disabled (no feedback)
const submitButton = screen.getByText("Create Refinement Task");
expect(submitButton.hasAttribute("disabled")).toBe(true);
// Enter feedback to enable it
const textarea = screen.getByPlaceholderText("Enter your feedback here...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "Some feedback" } });
});
expect(submitButton.hasAttribute("disabled")).toBe(false);
});
it("character count and submit button are siblings in the input group", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "done" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Open Actions dropdown first
const actionsBtn = screen.getByRole("button", { name: /actions/i });
fireEvent.click(actionsBtn);
// Click Refine from the dropdown
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
const inputGroup = container.querySelector(".detail-refine-input-group")!;
expect(inputGroup.querySelector(".detail-refine-char-count")).toBeTruthy();
expect(inputGroup.querySelector("button.btn-primary")).toBeTruthy();
});
});
describe("source issue metadata", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders source issue collapsed by default and expands details on toggle", async () => {
const user = userEvent.setup();
render(
<TaskDetailModal
task={makeTask({
sourceIssue: {
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "I_kgDOExample",
issueNumber: 2473,
url: "https://github.com/runfusion/fusion/issues/2473",
},
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Source issue")).toBeTruthy();
expect(screen.getByRole("link", { name: "(#2473)" })).toHaveAttribute(
"href",
"https://github.com/runfusion/fusion/issues/2473",
);
expect(screen.queryByText("Provider")).toBeNull();
const toggle = screen.getByRole("button", { name: "Expand source issue details" });
expect(toggle).toHaveAttribute("aria-expanded", "false");
const chevron = screen.getByTestId("chevron-right-icon");
expect(chevron.classList.contains("detail-source-chevron--expanded")).toBe(false);
await user.click(toggle);
expect(screen.getByRole("button", { name: "Collapse source issue details" })).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("Provider")).toBeTruthy();
expect(screen.getByText("github")).toBeTruthy();
expect(screen.getByText("runfusion/fusion")).toBeTruthy();
const sourceLink = screen.getByRole("link", { name: "https://github.com/runfusion/fusion/issues/2473" });
expect(sourceLink).toHaveAttribute("href", "https://github.com/runfusion/fusion/issues/2473");
expect(sourceLink).toHaveAttribute("target", "_blank");
expect(screen.getByTestId("chevron-right-icon").classList.contains("detail-source-chevron--expanded")).toBe(true);
});
it("hides source issue read section when sourceIssue metadata is missing", () => {
render(
<TaskDetailModal
task={makeTask({ sourceIssue: undefined })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText("Source issue")).toBeNull();
expect(screen.queryByText("No source issue metadata recorded.")).toBeNull();
});
it("prefills source issue inputs in edit mode", async () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "todo",
sourceIssue: {
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "I_kgDOExample",
issueNumber: 2473,
url: "https://github.com/runfusion/fusion/issues/2473",
},
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
await waitFor(() => {
expect((screen.getByTestId("task-source-provider-input") as HTMLInputElement).value).toBe("github");
});
expect((screen.getByTestId("task-source-repository-input") as HTMLInputElement).value).toBe("runfusion/fusion");
expect((screen.getByTestId("task-source-external-id-input") as HTMLInputElement).value).toBe("I_kgDOExample");
expect((screen.getByTestId("task-source-url-input") as HTMLInputElement).value).toBe("https://github.com/runfusion/fusion/issues/2473");
});
it("renders source issue block below Model Configuration in edit mode", async () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "todo",
sourceIssue: {
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "I_kgDOExample",
issueNumber: 2473,
},
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
await waitFor(() => {
const modelLabel = screen.getByText("Model Configuration");
const sourceLabel = screen.getByText("Source Issue");
const workflowSection = screen.getByTestId("workflow-steps-section");
expect(
modelLabel.compareDocumentPosition(sourceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(
sourceLabel.compareDocumentPosition(workflowSection) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
});
});
it("sends sourceIssue payload when source metadata is edited", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "todo",
sourceIssue: {
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "I_kgDOExample",
issueNumber: 2473,
url: "https://github.com/runfusion/fusion/issues/2473",
},
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByTestId("task-source-provider-input"), { target: { value: "gitlab" } });
fireEvent.change(screen.getByTestId("task-source-repository-input"), { target: { value: "runfusion/dashboard" } });
fireEvent.change(screen.getByTestId("task-source-external-id-input"), { target: { value: "I_kgDONew" } });
fireEvent.change(screen.getByTestId("task-source-url-input"), { target: { value: "https://gitlab.com/runfusion/dashboard/-/issues/2473" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({
sourceIssue: {
provider: "gitlab",
repository: "runfusion/dashboard",
externalIssueId: "I_kgDONew",
issueNumber: 2473,
url: "https://gitlab.com/runfusion/dashboard/-/issues/2473",
},
}), undefined);
});
});
it("sends sourceIssue: null when all source metadata fields are cleared", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "todo",
sourceIssue: {
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "I_kgDOExample",
issueNumber: 2473,
url: "https://github.com/runfusion/fusion/issues/2473",
},
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByTestId("task-source-provider-input"), { target: { value: "" } });
fireEvent.change(screen.getByTestId("task-source-repository-input"), { target: { value: "" } });
fireEvent.change(screen.getByTestId("task-source-external-id-input"), { target: { value: "" } });
fireEvent.change(screen.getByTestId("task-source-url-input"), { target: { value: "" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({ sourceIssue: null }), undefined);
});
});
it("keeps edit mode active and shows error toast when source metadata save fails", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockRejectedValueOnce(new Error("source patch failed"));
const addToast = vi.fn();
const { container } = render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "todo",
sourceIssue: {
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "I_kgDOExample",
issueNumber: 2473,
},
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByTestId("task-source-provider-input"), { target: { value: "gitlab" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to update FN-001: source patch failed", "error");
});
expect(container.querySelector("#task-form-title")).toBeTruthy();
});
});
describe("inline editing", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows Edit button in header when task is in triage column", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test task" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const editButton = container.querySelector(".modal-edit-btn");
expect(editButton).toBeTruthy();
});
it("shows Edit button in header when task is in todo column", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", title: "Test task" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const editButton = container.querySelector(".modal-edit-btn");
expect(editButton).toBeTruthy();
});
it("does not show Edit button when task is in in-progress column", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "in-progress", title: "Test task" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const editButton = container.querySelector(".modal-edit-btn");
expect(editButton).toBeNull();
});
it("does not show Edit button when already in edit mode", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test task" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
const editButton = container.querySelector(".modal-edit-btn");
expect(editButton).toBeTruthy();
fireEvent.click(editButton!);
// Edit button should be hidden now
expect(container.querySelector(".modal-edit-btn")).toBeNull();
// But TaskForm title input should be visible
expect(container.querySelector("#task-form-title")).toBeTruthy();
});
it("entering edit mode shows title input and description textarea", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test task", description: "Test description" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Initially shows title as h2
expect(container.querySelector("h2.detail-title")).toBeTruthy();
expect(container.querySelector("#task-form-title")).toBeNull();
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
// Now shows edit form with TaskForm fields
expect(container.querySelector("h2.detail-title")).toBeNull();
expect(container.querySelector("#task-form-title")).toBeTruthy();
expect(container.querySelector("#task-form-description")).toBeTruthy();
});
it("clicking Cancel exits edit mode without saving", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Original title", description: "Original description" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
// Change values
const titleInput = container.querySelector("#task-form-title") as HTMLInputElement;
fireEvent.change(titleInput, { target: { value: "Modified title" } });
// Click Cancel
fireEvent.click(screen.getByText("Cancel"));
// Should exit edit mode without saving
expect(container.querySelector("#task-form-title")).toBeNull();
expect(container.querySelector("h2.detail-title")?.textContent).toBe("Original title");
});
it("clicking Save calls updateTask with correct parameters", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Original title", description: "Original description" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
// Change values
const titleInput = container.querySelector("#task-form-title") as HTMLInputElement;
const descTextarea = container.querySelector("#task-form-description") as HTMLTextAreaElement;
fireEvent.change(titleInput, { target: { value: "New title" } });
fireEvent.change(descTextarea, { target: { value: "New description" } });
// Click Save
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({
title: "New title",
description: "New description",
}), undefined);
});
});
it("Save button is enabled in edit mode", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test title", description: "Test description" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
const saveButton = screen.getByText("Save");
expect(saveButton.hasAttribute("disabled")).toBe(false);
});
it("Save button shows 'Saving…' during save operation", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
// Delay the resolution to keep isSaving true
mockUpdate.mockImplementationOnce(() => new Promise(resolve => setTimeout(() => resolve({ id: "FN-001" } as Task), 100)));
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Original" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
const titleInput = container.querySelector("#task-form-title") as HTMLInputElement;
fireEvent.change(titleInput, { target: { value: "Changed title" } });
// Click Save
fireEvent.click(screen.getByText("Save"));
// Should show "Saving…" immediately
expect(screen.getByText("Saving…")).toBeTruthy();
});
it("successful save shows toast and exits edit mode", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
const addToast = vi.fn();
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Original" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
const titleInput = container.querySelector("#task-form-title") as HTMLInputElement;
fireEvent.change(titleInput, { target: { value: "Changed title" } });
// Click Save
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Updated FN-001", "success");
});
// Should exit edit mode
expect(container.querySelector("#task-form-title")).toBeNull();
});
it("failed save shows toast with error and stays in edit mode", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockRejectedValueOnce(new Error("Network error"));
const addToast = vi.fn();
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Original" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
const titleInput = container.querySelector("#task-form-title") as HTMLInputElement;
fireEvent.change(titleInput, { target: { value: "Changed title" } });
// Click Save
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to update FN-001: Network error", "error");
});
// Should stay in edit mode
expect(container.querySelector("#task-form-title")).toBeTruthy();
});
it("Escape key exits edit mode", async () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test title" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
expect(container.querySelector("#task-form-title")).toBeTruthy();
// Press Escape (handled via document-level keydown listener)
await act(async () => {
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true });
document.dispatchEvent(event);
});
// Should exit edit mode
expect(container.querySelector("#task-form-title")).toBeNull();
});
it("edit mode shows both title and description fields", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test title", description: "Test description" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
// Both title and description should be present in TaskForm
expect(container.querySelector("#task-form-title")).toBeTruthy();
expect(container.querySelector("#task-form-description")).toBeTruthy();
});
it("edit mode renders model configuration and workflow steps", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test task" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
// Model configuration and workflow steps should be present via TaskForm
expect(screen.getByText(/Model Configuration/i)).toBeTruthy();
expect(screen.getByText(/Workflow Steps/i)).toBeTruthy();
});
it("save sends only changed fields via updateTask", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test", description: "Desc", dependencies: ["FN-002"] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
const descTextarea = container.querySelector("#task-form-description") as HTMLTextAreaElement;
fireEvent.change(descTextarea, { target: { value: "Updated desc" } });
// Click Save
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", {
description: "Updated desc",
}, undefined);
});
});
it("includes priority in update payload only when changed", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValue({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test", description: "Desc", priority: "normal" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).not.toHaveBeenCalled();
});
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(container.querySelector("#task-priority") as HTMLSelectElement, { target: { value: "urgent" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { priority: "urgent" }, undefined);
});
});
it("sends executionMode: \"fast\" when changed from standard to fast", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValue({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test", description: "Desc", executionMode: "standard" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.change(screen.getByTestId("task-form-execution-mode-select"), { target: { value: "fast" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: "fast" }, undefined);
});
});
it("sends executionMode: null when changed from fast to standard", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValue({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test", description: "Desc", executionMode: "fast" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.change(screen.getByTestId("task-form-execution-mode-select"), { target: { value: "standard" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: null }, undefined);
});
});
it("omits executionMode from update payload when unchanged", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValue({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test", description: "Desc", executionMode: "fast" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).not.toHaveBeenCalled();
});
});
it("renders normalized priority in detail metadata", () => {
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", description: "Priority metadata", priority: undefined })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Priority: normal")).toBeTruthy();
});
it("pre-populates form with existing task values", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", title: "My Task", description: "My Description" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
const titleInput = container.querySelector("#task-form-title") as HTMLInputElement;
const descTextarea = container.querySelector("#task-form-description") as HTMLTextAreaElement;
expect(titleInput.value).toBe("My Task");
expect(descTextarea.value).toBe("My Description");
});
it("uses updated model values in edit mode after saving from the Model tab", async () => {
const { fetchModels, updateTask } = await import("../../api");
const mockFetchModels = vi.mocked(fetchModels);
const mockUpdateTask = vi.mocked(updateTask);
const user = userEvent.setup();
const availableModels = [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
];
mockFetchModels.mockResolvedValue({
models: availableModels,
favoriteProviders: [],
favoriteModels: [],
});
const initialTask = makeTask({ id: "FN-001", column: "triage", title: "Model sync test" });
const updatedAfterExecutor: Task = {
...initialTask,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
};
const updatedAfterValidator: Task = {
...updatedAfterExecutor,
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
};
mockUpdateTask
.mockResolvedValueOnce(updatedAfterExecutor)
.mockResolvedValueOnce(updatedAfterValidator);
function StatefulModal() {
const [task, setTask] = useState<TaskDetail>(initialTask);
return (
<TaskDetailModal
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onTaskUpdated={(updated) => setTask((prev) => ({ ...prev, ...updated }))}
addToast={noop}
/>
);
}
const { container } = render(<StatefulModal />);
await user.click(screen.getByText("Model"));
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
await user.click(screen.getByLabelText("Executor Model"));
await user.click(screen.getByText("Claude Sonnet 4.5"));
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenNthCalledWith(
1,
"FN-001",
expect.objectContaining({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
}),
);
});
await user.click(screen.getByLabelText("Reviewer Model"));
await user.click(screen.getByText("GPT-4o"));
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenNthCalledWith(
2,
"FN-001",
{
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
},
);
});
fireEvent.click(container.querySelector(".modal-edit-btn")!);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toHaveTextContent("Claude Sonnet 4.5");
expect(screen.getByLabelText("Reviewer Model")).toHaveTextContent("GPT-4o");
});
});
it("renders Save and Cancel in the modal footer, not inside the edit form body", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test task" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
// The edit form body should NOT contain the Save or Cancel action buttons
const editForm = container.querySelector(".modal-edit-form");
expect(editForm).toBeTruthy();
const formButtons = Array.from(editForm!.querySelectorAll("button"));
const formButtonTexts = formButtons.map((b) => b.textContent);
expect(formButtonTexts).not.toContain("Save");
expect(formButtonTexts).not.toContain("Cancel");
expect(formButtonTexts).not.toContain("Saving…");
// The modal-actions footer should contain the Save and Cancel buttons
const modalActions = container.querySelector(".modal-actions");
expect(modalActions).toBeTruthy();
const footerButtons = modalActions!.querySelectorAll("button");
const buttonTexts = Array.from(footerButtons).map((b) => b.textContent);
expect(buttonTexts).toContain("Cancel");
expect(buttonTexts).toContain("Save");
});
it("renders keyboard hint in the modal footer when editing", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test task" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
// The hint should be in the modal-actions footer, not inside the edit form body
const editForm = container.querySelector(".modal-edit-form");
expect(editForm!.querySelector(".modal-edit-hint")).toBeNull();
const modalActions = container.querySelector(".modal-actions");
expect(modalActions!.querySelector(".modal-edit-hint")).toBeTruthy();
});
it("shows normal modal actions (not edit actions) when not editing", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", title: "Test task" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Should NOT be in edit mode — no edit hint, no Save/Cancel in footer
const modalActions = container.querySelector(".modal-actions");
expect(modalActions!.querySelector(".modal-edit-hint")).toBeNull();
const footerButtons = modalActions!.querySelectorAll("button");
const buttonTexts = Array.from(footerButtons).map((b) => b.textContent);
expect(buttonTexts).not.toContain("Save");
expect(buttonTexts).not.toContain("Cancel");
// Should contain Actions dropdown and Move primary action
expect(buttonTexts).toContain("Actions");
expect(buttonTexts.some((t) => t?.includes("Move to"))).toBe(true);
});
});
describe("Commits tab visibility", () => {
it.each<[string, Parameters<typeof makeTask>[0]]>([
["with mergeDetails.commitSha", { column: "done", mergeDetails: { commitSha: "abc1234567890", filesChanged: 3, insertions: 10, deletions: 2 } }],
["with mergeDetails but no commitSha", { column: "done", mergeDetails: { filesChanged: 3 } }],
["without mergeDetails", { column: "done" }],
])("never shows a separate Commits tab for done tasks (%s) — changes are in the Changes tab", (_label, taskOverrides) => {
const { container } = render(
<TaskDetailModal
task={makeTask(taskOverrides)}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText("Commits")).toBeNull();
const tabTexts = Array.from(container.querySelectorAll(".detail-tab")).map((t) => t.textContent);
expect(tabTexts).toContain("Changes");
});
});
describe("comment state propagation (FN-845)", () => {
it("passes onTaskUpdated to TaskComments when provided", async () => {
const { addSteeringComment } = await import("../../api");
const onTaskUpdated = vi.fn();
const updatedTask = makeTask({
comments: [{ id: "c1", text: "New comment", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }],
});
vi.mocked(addSteeringComment).mockResolvedValueOnce(updatedTask);
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onTaskUpdated={onTaskUpdated}
addToast={noop}
/>,
);
// Switch to Comments tab
fireEvent.click(screen.getByText("Comments"));
// Add a comment
fireEvent.change(screen.getByPlaceholderText(/Add a comment/), { target: { value: "New comment" } });
fireEvent.click(screen.getByText("Add Comment"));
await waitFor(() => {
expect(addSteeringComment).toHaveBeenCalledWith("FN-099", "New comment", undefined);
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
});
});
it("comment mutations still work when onTaskUpdated is not provided", async () => {
const { addSteeringComment } = await import("../../api");
const addToast = vi.fn();
vi.mocked(addSteeringComment).mockResolvedValueOnce(makeTask({
comments: [{ id: "c1", text: "Hello", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }],
}));
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
// Switch to Comments tab
fireEvent.click(screen.getByText("Comments"));
// Add a comment — should succeed without error even without onTaskUpdated
fireEvent.change(screen.getByPlaceholderText(/Add a comment/), { target: { value: "Hello" } });
fireEvent.click(screen.getByText("Add Comment"));
await waitFor(() => {
expect(addSteeringComment).toHaveBeenCalledWith("FN-099", "Hello", undefined);
expect(addToast).toHaveBeenCalledWith("Comment added", "success");
});
});
});
describe("Workflow step ordering in edit mode (FN-836)", () => {
it("sends ordered enabledWorkflowSteps when saving with reordered steps", async () => {
const { updateTask, fetchWorkflowSteps } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
]);
const { container } = render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "triage",
title: "Test",
description: "Desc",
enabledWorkflowSteps: ["WS-001", "WS-002"],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
// Wait for workflow steps to load and reorder controls to appear
await waitFor(() => {
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
});
// Move WS-002 up (swap with WS-001)
fireEvent.click(screen.getByTestId("workflow-step-move-up-WS-002"));
// Save
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({
enabledWorkflowSteps: ["WS-002", "WS-001"],
}), undefined);
});
});
});
describe("Workflow tab", () => {
it.each<[string, Parameters<typeof makeTask>[0]]>([
["empty enabledWorkflowSteps", { enabledWorkflowSteps: [] }],
["undefined enabledWorkflowSteps", { enabledWorkflowSteps: undefined, workflowStepResults: undefined }],
["non-empty enabledWorkflowSteps", { enabledWorkflowSteps: ["WS-001"] }],
["previous workflow results", { enabledWorkflowSteps: [], workflowStepResults: [{ workflowStepId: "WS-001", workflowStepName: "QA Check", status: "passed" }] }],
])("Workflow tab is always rendered (%s)", (_label, taskOverrides) => {
render(
<TaskDetailModal
task={makeTask(taskOverrides)}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Workflow")).toBeTruthy();
});
it("switches to Workflow tab and calls fetchWorkflowResults", async () => {
const { fetchWorkflowResults } = await import("../../api");
const mockFetch = vi.mocked(fetchWorkflowResults);
const mockResults: import("@fusion/core").WorkflowStepResult[] = [
{
workflowStepId: "WS-001",
workflowStepName: "QA Check",
status: "passed",
output: "All tests passed.",
startedAt: "2026-04-04T10:00:00Z",
completedAt: "2026-04-04T10:02:00Z",
},
];
mockFetch.mockResolvedValueOnce(mockResults);
render(
<TaskDetailModal
task={makeTask({ enabledWorkflowSteps: ["WS-001"] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByText("Workflow"));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("FN-099", undefined);
});
// Should render the workflow results after async tab load completes
expect(await screen.findByText("QA Check", {}, { timeout: 15_000 })).toBeTruthy();
});
it("shows loading state when workflow results are being fetched", async () => {
const { fetchWorkflowResults } = await import("../../api");
const mockFetch = vi.mocked(fetchWorkflowResults);
// Never resolve to keep loading state
mockFetch.mockResolvedValueOnce(new Promise(() => {}) as any);
render(
<TaskDetailModal
task={makeTask({ enabledWorkflowSteps: ["WS-001"] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByText("Workflow"));
await waitFor(() => {
expect(screen.getByTestId("workflow-results-loading")).toBeTruthy();
});
});
it("shows error toast when fetchWorkflowResults fails", async () => {
const { fetchWorkflowResults } = await import("../../api");
const mockFetch = vi.mocked(fetchWorkflowResults);
mockFetch.mockRejectedValueOnce(new Error("Server error"));
const addToast = vi.fn();
render(
<TaskDetailModal
task={makeTask({ enabledWorkflowSteps: ["WS-001"] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
fireEvent.click(screen.getByText("Workflow"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
"Failed to load workflow results: Server error",
"error",
);
});
});
it("renders configured workflow steps state when results are empty", async () => {
const { fetchWorkflowResults } = await import("../../api");
const mockFetch = vi.mocked(fetchWorkflowResults);
mockFetch.mockResolvedValueOnce([]);
render(
<TaskDetailModal
task={makeTask({ enabledWorkflowSteps: ["WS-001"] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByText("Workflow"));
await waitFor(() => {
expect(screen.getByTestId("workflow-configured-steps")).toBeTruthy();
expect(screen.getByTestId("workflow-configured-step-WS-001")).toHaveTextContent("WS-001");
});
});
it("renders multiple workflow step results with status badges", async () => {
const { fetchWorkflowResults } = await import("../../api");
const mockFetch = vi.mocked(fetchWorkflowResults);
const mockResults: import("@fusion/core").WorkflowStepResult[] = [
{
workflowStepId: "WS-001",
workflowStepName: "QA Check",
status: "passed",
output: "All tests passed.",
startedAt: "2026-04-04T10:00:00Z",
completedAt: "2026-04-04T10:02:00Z",
},
{
workflowStepId: "WS-002",
workflowStepName: "Security Audit",
status: "failed",
output: "Found 2 issues.",
startedAt: "2026-04-04T10:02:05Z",
completedAt: "2026-04-04T10:03:00Z",
},
];
mockFetch.mockResolvedValueOnce(mockResults);
render(
<TaskDetailModal
task={makeTask({ enabledWorkflowSteps: ["WS-001", "WS-002"] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByText("Workflow"));
await waitFor(() => {
expect(screen.getByText("QA Check")).toBeTruthy();
expect(screen.getByText("Security Audit")).toBeTruthy();
expect(screen.getByTestId("workflow-result-badge-WS-001")).toHaveTextContent("Passed");
expect(screen.getByTestId("workflow-result-badge-WS-002")).toHaveTextContent("Failed");
});
});
it("hides Definition content when Workflow tab is active", async () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
enabledWorkflowSteps: ["WS-001"],
prompt: "# Test prompt",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Definition content visible initially
expect(container.querySelector(".markdown-body")).toBeTruthy();
// Switch to Workflow tab
fireEvent.click(screen.getByText("Workflow"));
// Definition content should be hidden
await waitFor(() => {
expect(container.querySelector(".markdown-body")).toBeNull();
});
});
});
describe("agent assignment", () => {
it("shows Assign Agent button when task has no assigned agent", () => {
render(
<TaskDetailModal
task={makeTask({ assignedAgentId: undefined })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByRole("button", { name: "Assign Agent" })).toBeInTheDocument();
});
it("shows assigned agent chip and clear button when task has assignedAgentId", async () => {
const { fetchAgent } = await import("../../api");
vi.mocked(fetchAgent).mockResolvedValue({
id: "agent-002",
name: "Pipeline Helper",
role: "executor",
state: "active",
metadata: {},
heartbeatHistory: [],
completedRuns: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
} as any);
render(
<TaskDetailModal
task={makeTask({ assignedAgentId: "agent-002" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await waitFor(() => {
expect(screen.getByText("Pipeline Helper")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Unassign agent" })).toBeInTheDocument();
});
});
it("assigns selected agent via assignTask", async () => {
const { fetchAgents, assignTask } = await import("../../api");
vi.mocked(fetchAgents).mockResolvedValue([
{
id: "agent-001",
name: "Task Runner",
role: "executor",
state: "active",
metadata: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
] as any);
vi.mocked(assignTask).mockResolvedValue(makeTask({ assignedAgentId: "agent-001" }) as any);
render(
<TaskDetailModal
task={makeTask({ assignedAgentId: undefined })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Assign Agent" }));
await userEvent.click(screen.getByRole("button", { name: /Task Runner/i }));
await waitFor(() => {
expect(assignTask).toHaveBeenCalledWith("FN-099", "agent-001", undefined);
});
});
it("clears assigned agent via assignTask(null)", async () => {
const { fetchAgent, assignTask } = await import("../../api");
vi.mocked(fetchAgent).mockResolvedValue({
id: "agent-005",
name: "Doc Bot",
role: "executor",
state: "active",
metadata: {},
heartbeatHistory: [],
completedRuns: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
} as any);
vi.mocked(assignTask).mockResolvedValue(makeTask({ assignedAgentId: undefined }) as any);
render(
<TaskDetailModal
task={makeTask({ assignedAgentId: "agent-005" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Unassign agent" }));
await waitFor(() => {
expect(assignTask).toHaveBeenCalledWith("FN-099", null, undefined);
});
});
});
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.mockResolvedValueOnce(new Promise(() => {}) as any);
const task: Task = {
id: "FN-203",
description: "Loading spec test",
column: "todo",
dependencies: [],
steps: [{ name: "Plan", status: "in-progress" }],
currentStep: 0,
log: [{ timestamp: "2026-04-24T09:00:00.000Z", action: "[timing] setup in 120ms" }],
executionMode: "fast",
status: "executing",
assignedAgentId: "agent-loading",
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();
// Token stats now live in their own Stats tab — switch to it before
// asserting on token-loading text.
fireEvent.click(screen.getByRole("button", { name: "Stats" }));
expect(screen.getByText("Execution Timing")).toBeInTheDocument();
expect(screen.getByText("Execution Details")).toBeInTheDocument();
expect(screen.getByText("Loading token statistics…")).toBeDefined();
expect(screen.getByText("Fast")).toBeInTheDocument();
expect(screen.getByText("executing")).toBeInTheDocument();
});
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.",
log: [
{ timestamp: "2026-04-24T09:00:00.000Z", action: "[timing] prepare env in 120ms" },
{ timestamp: "2026-04-24T09:01:00.000Z", action: "[timing] run tests in 3400ms" },
],
workflowStepResults: [
{
workflowStepId: "WS-101",
workflowStepName: "Workflow QA",
status: "passed",
startedAt: "2026-04-24T09:10:00.000Z",
completedAt: "2026-04-24T09:10:07.000Z",
},
],
executionMode: "fast",
status: "executing",
mergeRetries: 1,
workflowStepRetries: 2,
recoveryRetryCount: 3,
taskDoneRetryCount: 4,
tokenUsage: {
inputTokens: 1200,
outputTokens: 450,
cachedTokens: 210,
totalTokens: 1860,
firstUsedAt: "2026-04-24T09:00:00.000Z",
lastUsedAt: "2026-04-24T10:15:00.000Z",
},
} 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();
// Token stats live behind the Stats tab now.
fireEvent.click(screen.getByRole("button", { name: "Stats" }));
expect(screen.queryByText("Loading token statistics…")).toBeNull();
expect(screen.getByText("Execution Timing")).toBeInTheDocument();
expect(screen.getByText("Execution Details")).toBeInTheDocument();
expect(screen.getByText("Timing events")).toBeInTheDocument();
expect(screen.getByText("Workflow runtime")).toBeInTheDocument();
expect(screen.getByText("Execution mode")).toBeInTheDocument();
expect(screen.getByText("Runtime status")).toBeInTheDocument();
expect(screen.getByText("Fast")).toBeInTheDocument();
expect(screen.getByText("executing")).toBeInTheDocument();
expect(screen.getByText((1200).toLocaleString())).toBeInTheDocument();
expect(screen.getByText((450).toLocaleString())).toBeInTheDocument();
expect(screen.getByText((210).toLocaleString())).toBeInTheDocument();
expect(screen.getByText((1860).toLocaleString())).toBeInTheDocument();
const firstUsed = container.querySelector('time[datetime="2026-04-24T09:00:00.000Z"]');
const lastUsed = container.querySelector('time[datetime="2026-04-24T10:15:00.000Z"]');
expect(firstUsed).toBeTruthy();
expect(lastUsed).toBeTruthy();
});
it("preserves fullDetail.log when SSE-stripped task prop has empty log", async () => {
// Regression: SSE strips `log` to [] in task list payloads (see
// stripTaskListHeavyFields in packages/dashboard/src/sse.ts). The modal
// merges live `task` over `fullDetail` to keep tokenUsage/status fresh,
// which previously clobbered fullDetail.log and emptied the Activity tab.
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
const strippedTask: Task = {
id: "FN-LOG-1",
description: "SSE stripped task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
};
mockFetch.mockResolvedValueOnce({
...strippedTask,
prompt: "# Spec",
log: [
{ timestamp: "2026-04-24T09:00:00.000Z", action: "Created task" },
{ timestamp: "2026-04-24T09:01:00.000Z", action: "Started executor", outcome: "OK" },
],
} as TaskDetail);
const { container } = render(
<TaskDetailModal
task={strippedTask}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Wait for fetchTaskDetail to resolve.
await waitFor(() => {
expect(container.querySelector(".markdown-body")).toBeTruthy();
}, { timeout: 3000 });
fireEvent.click(screen.getByText("Logs"));
const activityList = container.querySelector(".detail-activity-list");
expect(activityList).toBeTruthy();
const logEntries = container.querySelectorAll(".detail-log-entry");
expect(logEntries).toHaveLength(2);
expect(logEntries[0].textContent).toContain("Started executor");
expect(logEntries[1].textContent).toContain("Created task");
});
it("shows token stats empty state once detail is loaded without usage", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
const task: Task = {
id: "FN-205",
description: "No token stats",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
} as Task;
mockFetch.mockResolvedValueOnce({
...task,
prompt: "# Async Spec\n\nSpec without usage.",
tokenUsage: undefined,
} as TaskDetail);
render(
<TaskDetailModal
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Token stats live behind the Stats tab now — wait for the modal to
// settle, then switch tabs and assert on the empty state.
await waitFor(() => {
expect(screen.queryByText("Loading specification…")).toBeNull();
});
fireEvent.click(screen.getByRole("button", { name: "Stats" }));
await waitFor(() => {
expect(screen.getByText("No token usage recorded for this task yet.")).toBeInTheDocument();
});
});
});
describe("PluginSlot integration", () => {
it("renders plugin tabs when plugins register for task-detail-tab slot", async () => {
mockUsePluginUiSlots.mockReturnValue({
slots: [
{ pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } },
{ pluginId: "plugin-b", slot: { slotId: "task-detail-tab", label: "Plugin B Tab", componentPath: "./b.js" } },
],
getSlotsForId: (id: string) => id === "task-detail-tab" ? [
{ pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } },
{ pluginId: "plugin-b", slot: { slotId: "task-detail-tab", label: "Plugin B Tab", componentPath: "./b.js" } },
] : [],
loading: false,
error: null,
} as any);
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onOpenDetail={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>
);
// Both plugin tabs should appear
expect(screen.getByText("Plugin A Tab")).toBeDefined();
expect(screen.getByText("Plugin B Tab")).toBeDefined();
});
it("shows only the selected plugin tab content when plugin tab is clicked", async () => {
mockUsePluginUiSlots.mockReturnValue({
slots: [
{ pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } },
{ pluginId: "plugin-b", slot: { slotId: "task-detail-tab", label: "Plugin B Tab", componentPath: "./b.js" } },
],
getSlotsForId: (id: string) => id === "task-detail-tab" ? [
{ pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } },
{ pluginId: "plugin-b", slot: { slotId: "task-detail-tab", label: "Plugin B Tab", componentPath: "./b.js" } },
] : [],
loading: false,
error: null,
} as any);
const { container } = render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onOpenDetail={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>
);
await userEvent.click(screen.getByText("Plugin B Tab"));
const slots = container.querySelectorAll('[data-slot-id="task-detail-tab"]');
expect(slots).toHaveLength(1);
expect(slots[0]).toHaveAttribute("data-plugin-id", "plugin-b");
expect(container.querySelector('[data-plugin-id="plugin-a"]')).toBeNull();
});
it("renders no extra tabs when no plugins register", () => {
mockUsePluginUiSlots.mockReturnValue({
slots: [],
getSlotsForId: vi.fn(() => []),
loading: false,
error: null,
});
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onOpenDetail={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>
);
// Only standard tabs should be visible (Definition, Logs, etc.)
expect(screen.getByText("Definition")).toBeDefined();
expect(screen.getByText("Logs")).toBeDefined();
// Plugin tabs should not exist
expect(screen.queryByText("Plugin A Tab")).toBeNull();
});
});
});