Files
fusion/packages/dashboard/app/components/__tests__/TaskDetailModal.attachments-and-tabs.test.tsx
Fusion 08429a6f42 feat(FN-4260): add SkillsView component styles and finalize dashboard test
Finalizes the dashboard test baseline restoration by updating CSS for AgentsView and adding a new SkillsView.css stylesheet, while adjusting several related component tests to align with the restored visual baselines.

Fusion-Task-Id: FN-4260
2026-05-13 06:29:34 -07:00

854 lines
31 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
makeTask,
noop,
noopDelete,
noopMerge,
noopMove,
noopOpenDetail,
mockConfirm,
mockUsePluginUiSlots,
expectBaseRule,
getCssRuleBlock,
readDashboardStylesSource,
setupTaskDetailModalHooks,
} from "./TaskDetailModal.test-helpers";
import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
setupTaskDetailModalHooks();
describe("TaskDetailModal", () => {
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(".task-detail-content")!;
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("wraps in-review PR content in a spaced detail section after dependencies", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ column: "in-review", status: "creating-pr", dependencies: ["FN-001"] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const depsSection = container.querySelector(".detail-deps");
const blockingSection = container.querySelector(".detail-deps.detail-blocking");
const prSection = container.querySelector(".detail-pr-section");
expect(depsSection).toBeTruthy();
expect(blockingSection).toBeTruthy();
expect(prSection).toBeTruthy();
expect(blockingSection?.nextElementSibling).toBe(prSection);
expect(prSection?.querySelector(".pr-section")).toBeTruthy();
});
it("defines tokenized margin on detail-pr-section spacing contract", () => {
const css = readDashboardStylesSource();
expectBaseRule(css, ".detail-pr-section", "margin-top: var(--space-lg);");
});
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, Review, Comments,
// Documents, Model, Workflow, Stats, Routing.
const tabTexts = ["Definition", "Logs", "Changes", "Review", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing"];
const tabs = screen.getAllByRole("button").filter((b) =>
tabTexts.includes(b.textContent || "")
);
expect(tabs.length).toBe(10);
expect(tabs[0].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Logs");
expect(tabs[2].textContent).toBe("Changes");
expect(tabs[3].textContent).toBe("Review");
expect(tabs[4].textContent).toBe("Comments");
expect(tabs[5].textContent).toBe("Documents");
expect(tabs[6].textContent).toBe("Model");
expect(tabs[7].textContent).toBe("Workflow");
expect(tabs[8].textContent).toBe("Stats");
expect(tabs[9].textContent).toBe("Routing");
// Activity and Agent Log are NOT top-level tabs (they are subviews inside Logs)
expect(container.querySelectorAll(".detail-tab").length).toBe(10);
// 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();
});
});
});