Files
fusion/packages/dashboard/app/components/__tests__/TaskDetailModal.task-activity-chat.test.tsx
gsxdsm 69f754f868 fix: anchor task Activity view dropdown under its tab
The Activity view menu (Live/Feed/Raw) is position:fixed and portaled to
<body>, so it is anchored to the layout viewport. It was clamped using
window.visualViewport width/offset, which under pinch-zoom or an open mobile
keyboard diverges from the layout viewport and shoved the popup to the left of
the modal. Position it purely from the layout viewport
(document.documentElement.clientWidth/clientHeight) with no visual-viewport
offset so it stays under the "Activity" trigger.

Adds a regression test asserting the menu anchors under the trigger even when
the visual viewport diverges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:42:23 -07:00

356 lines
17 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React, { type ComponentProps } from "react";
import type { AgentLogEntry } from "@fusion/core";
import {
makeTask,
noop,
noopDelete,
noopMerge,
noopMove,
noopOpenDetail,
setupTaskDetailModalHooks,
} from "./TaskDetailModal.test-helpers";
import { TaskDetailContent, TaskDetailModal } from "../TaskDetailModal";
import { useAgentLogs } from "../../hooks/useAgentLogs";
setupTaskDetailModalHooks();
const mockedUseAgentLogs = vi.mocked(useAgentLogs);
function renderModal(props: Partial<ComponentProps<typeof TaskDetailModal>> = {}) {
return render(
<TaskDetailModal
task={makeTask({
id: "FN-7315",
column: "in-progress" as any,
log: [
{ timestamp: "2026-06-30T20:00:00.000Z", action: "Started work", outcome: "Executor checked out" },
{ timestamp: "2026-06-30T20:01:00.000Z", action: "Posted update" },
],
steeringComments: [
{ id: "steer-existing", text: "Existing steering guidance", author: "user", createdAt: "2026-06-30T20:02:00.000Z" },
],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
{...props}
/>,
);
}
function topLevelTabLabels(): string[] {
return Array.from(document.querySelectorAll<HTMLButtonElement>(".detail-tabs .detail-tab"))
.map((button) => button.textContent?.trim() ?? "");
}
type ActivitySegmentTestValue = "current" | "feed" | "raw-logs";
const ACTIVITY_VIEW_LABELS: Record<ActivitySegmentTestValue, string> = {
current: "Live",
feed: "Feed",
"raw-logs": "Raw",
};
function openActivityViewMenu() {
const existingMenu = screen.queryByRole("menu", { name: "Activity views" });
if (!existingMenu) {
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
}
return screen.getByRole("menu", { name: "Activity views" });
}
function activityViewLabels(): string[] {
openActivityViewMenu();
return screen.getAllByRole("menuitem").map((option) => option.textContent?.trim() ?? "");
}
function expectActivityView(value: ActivitySegmentTestValue) {
openActivityViewMenu();
expect(screen.getByRole("menuitem", { name: ACTIVITY_VIEW_LABELS[value] })).toHaveAttribute("aria-current", "true");
}
function selectActivityView(value: ActivitySegmentTestValue) {
openActivityViewMenu();
fireEvent.click(screen.getByRole("menuitem", { name: ACTIVITY_VIEW_LABELS[value] }));
}
function mockRawLogs(entries: AgentLogEntry[]) {
mockedUseAgentLogs.mockReturnValue({
entries,
loading: false,
clear: vi.fn(),
loadMore: vi.fn(async () => {}),
hasMore: false,
total: entries.length,
loadingMore: false,
});
}
describe("TaskDetailModal Activity and planner Chat tab integration", () => {
it("defaults to Activity first while selecting Live, Feed, and Raw without duplicate panels on desktop", async () => {
const user = userEvent.setup();
mockRawLogs([
{ timestamp: "2026-06-30T20:03:00.000Z", taskId: "FN-7315", type: "text", agent: "executor", text: "raw executor line" },
] as AgentLogEntry[]);
renderModal();
expect(topLevelTabLabels().slice(0, 2)).toEqual(["Activity", "Chat"]);
expect(screen.getAllByRole("button", { name: "Chat" })).toHaveLength(1);
expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active");
expect(screen.queryByTestId("task-planner-chat-panel")).not.toBeInTheDocument();
expect(activityViewLabels()).toEqual(["Live", "Feed", "Raw"]);
expectActivityView("current");
expect(document.querySelector(".activity-view-select")).toBeNull();
expect(screen.queryByRole("combobox", { name: "Activity view" })).not.toBeInTheDocument();
expect(document.querySelector(".activity-segmented-control")).toBeNull();
expect(document.querySelector(".activity-segment")).toBeNull();
expect(screen.queryByRole("tablist", { name: "Activity views" })).not.toBeInTheDocument();
expect(screen.getAllByRole("form", { name: "Task activity composer" })).toHaveLength(1);
expect(screen.queryByText(/^Steering comment$/)).not.toBeInTheDocument();
expect(screen.queryByText("Send operational guidance to the active task through steering comments.")).not.toBeInTheDocument();
expect(screen.getByText("Existing steering guidance")).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Feed" })).not.toBeInTheDocument();
expect(screen.queryByTestId("agent-log-viewer")).not.toBeInTheDocument();
selectActivityView("feed");
expectActivityView("feed");
expect(screen.queryByRole("form", { name: "Task activity composer" })).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Feed" })).toBeInTheDocument();
expect(screen.getByText("Posted update")).toBeInTheDocument();
expect(screen.queryByText("Existing steering guidance")).not.toBeInTheDocument();
expect(screen.queryByTestId("agent-log-viewer")).not.toBeInTheDocument();
selectActivityView("raw-logs");
expectActivityView("raw-logs");
expect(screen.queryByRole("form", { name: "Task activity composer" })).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Feed" })).not.toBeInTheDocument();
expect(screen.getByTestId("agent-log-viewer")).toBeInTheDocument();
expect(screen.getByText("raw executor line")).toBeInTheDocument();
});
it("portals the mobile Activity view menu outside the tab scroller while keeping tabs and content visible", () => {
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
Object.defineProperty(window, "innerHeight", { configurable: true, value: 844 });
HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
if (this.classList.contains("detail-tab--activity")) {
return { x: 24, y: 96, top: 96, right: 116, bottom: 132, left: 24, width: 92, height: 36, toJSON: () => ({}) } as DOMRect;
}
return originalGetBoundingClientRect.call(this);
};
try {
mockRawLogs([
{ timestamp: "2026-06-30T20:03:00.000Z", taskId: "FN-7315", type: "text", agent: "executor", text: "raw executor line" },
] as AgentLogEntry[]);
renderModal();
const tabs = document.querySelector(".detail-tabs");
expect(tabs).not.toBeNull();
expect(screen.getByText("Existing steering guidance")).toBeInTheDocument();
const menu = openActivityViewMenu();
expect(menu.parentElement).toBe(document.body);
expect(tabs).not.toContainElement(menu);
expect(document.querySelector(".detail-tab-dropdown")?.contains(menu)).toBe(false);
expect(menu).toHaveStyle({ position: "fixed" });
expect(menu.style.top).not.toBe("");
expect(menu.style.left).not.toBe("");
expect(activityViewLabels()).toEqual(["Live", "Feed", "Raw"]);
expect(topLevelTabLabels()).toEqual(expect.arrayContaining(["Activity", "Chat", "Plan", "Changes", "Review"]));
expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Plan" })).toBeInTheDocument();
expect(screen.getByText("Existing steering guidance")).toBeInTheDocument();
fireEvent.keyDown(menu, { key: "Escape" });
expect(screen.queryByRole("menu", { name: "Activity views" })).not.toBeInTheDocument();
expect(topLevelTabLabels()).toEqual(expect.arrayContaining(["Activity", "Chat", "Plan", "Changes", "Review"]));
expect(screen.getByText("Existing steering guidance")).toBeInTheDocument();
selectActivityView("feed");
expect(screen.getByRole("heading", { name: "Feed" })).toBeInTheDocument();
expect(screen.getByText("Posted update")).toBeInTheDocument();
expect(topLevelTabLabels()).toEqual(expect.arrayContaining(["Activity", "Chat", "Plan", "Changes", "Review"]));
selectActivityView("raw-logs");
expect(screen.getByTestId("agent-log-viewer")).toBeInTheDocument();
expect(screen.getByText("raw executor line")).toBeInTheDocument();
selectActivityView("current");
expect(screen.getByText("Existing steering guidance")).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Feed" })).not.toBeInTheDocument();
expect(screen.queryByTestId("agent-log-viewer")).not.toBeInTheDocument();
} finally {
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
Object.defineProperty(window, "innerHeight", { configurable: true, value: originalInnerHeight });
}
});
// FN-7375 regression: the position:fixed Activity menu is anchored to the layout viewport, so a
// diverging window.visualViewport (pinch-zoom / open mobile keyboard: smaller width, nonzero
// offsetLeft) must NOT shift the menu away from the trigger. Symptom was the popup rendering
// detached to the far left of the modal instead of under the "Activity" tab.
it("anchors the Activity menu under the trigger regardless of a diverging visual viewport", () => {
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
const originalVisualViewport = Object.getOwnPropertyDescriptor(window, "visualViewport");
const documentElement = document.documentElement;
// Layout viewport is 1280x800 (wide desktop); the Activity trigger sits well inside it.
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1280 });
Object.defineProperty(window, "innerHeight", { configurable: true, value: 800 });
Object.defineProperty(documentElement, "clientWidth", { configurable: true, value: 1280 });
Object.defineProperty(documentElement, "clientHeight", { configurable: true, value: 800 });
// A pinch-zoomed visual viewport: much smaller and panned. The old code fed this into a fixed
// element's clamp and shoved it to the left edge.
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: { width: 320, height: 480, offsetLeft: 240, offsetTop: 120, addEventListener: vi.fn(), removeEventListener: vi.fn() },
});
HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
if (this.classList.contains("detail-tab--activity")) {
return { x: 700, y: 96, top: 96, right: 792, bottom: 132, left: 700, width: 92, height: 36, toJSON: () => ({}) } as DOMRect;
}
return originalGetBoundingClientRect.call(this);
};
try {
renderModal();
const menu = openActivityViewMenu();
// Left edge anchored under the trigger (rect.left = 700), NOT clamped to a shrunken visual
// viewport and NOT displaced by visualViewport.offsetLeft.
expect(menu.style.left).toBe("700px");
// Opens below the trigger (rect.bottom = 132) + gap, unaffected by visualViewport.offsetTop.
expect(menu.style.top).toBe("136px");
} finally {
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
Object.defineProperty(window, "innerHeight", { configurable: true, value: originalInnerHeight });
if (originalVisualViewport) {
Object.defineProperty(window, "visualViewport", originalVisualViewport);
} else {
delete (window as unknown as { visualViewport?: unknown }).visualViewport;
}
delete (documentElement as unknown as { clientWidth?: number }).clientWidth;
delete (documentElement as unknown as { clientHeight?: number }).clientHeight;
}
});
it("restores Chat-first ordering and omitted non-done default when the project setting is enabled", () => {
mockRawLogs([]);
renderModal({ taskDetailChatFirst: true });
expect(topLevelTabLabels().slice(0, 2)).toEqual(["Chat", "Activity"]);
expect(screen.getByRole("button", { name: "Chat" })).toHaveClass("detail-tab-active");
expect(screen.getByTestId("task-planner-chat-panel")).toBeInTheDocument();
expect(screen.queryByRole("tablist", { name: "Activity views" })).not.toBeInTheDocument();
});
it("keeps explicit Activity, planner Chat, and Logs deep links stable across the ordering setting", () => {
mockRawLogs([]);
const { rerender } = renderModal({ initialTab: "chat", taskDetailChatFirst: true });
expect(topLevelTabLabels().slice(0, 2)).toEqual(["Chat", "Activity"]);
expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active");
expectActivityView("current");
expect(screen.queryByTestId("task-planner-chat-panel")).not.toBeInTheDocument();
rerender(
<TaskDetailModal
task={makeTask({ id: "FN-7315", column: "in-progress" as any, log: [], steeringComments: [] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
initialTab="planner-chat"
taskDetailChatFirst={false}
/>,
);
expect(topLevelTabLabels().slice(0, 2)).toEqual(["Activity", "Chat"]);
expect(screen.getByRole("button", { name: "Chat" })).toHaveClass("detail-tab-active");
expect(screen.getByTestId("task-planner-chat-panel")).toBeInTheDocument();
rerender(
<TaskDetailModal
task={makeTask({ id: "FN-7315", column: "in-progress" as any, log: [{ timestamp: "2026-06-30T20:01:00.000Z", action: "Posted update" }], steeringComments: [] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
initialTab="logs"
taskDetailChatFirst={true}
/>,
);
expect(topLevelTabLabels().slice(0, 2)).toEqual(["Chat", "Activity"]);
expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active");
expectActivityView("feed");
expect(screen.getByRole("heading", { name: "Feed" })).toBeInTheDocument();
});
it("preserves Summary as the done-task mobile default while Activity and Chat remain first", async () => {
const user = userEvent.setup();
const originalInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
window.dispatchEvent(new Event("resize"));
try {
mockRawLogs([]);
render(
<TaskDetailContent
task={makeTask({ id: "FN-7315-DONE", column: "done" as any, log: [], steeringComments: [] })}
embedded
onRequestClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(topLevelTabLabels().slice(0, 3)).toEqual(["Activity", "Chat", "Summary"]);
expect(screen.getByRole("button", { name: "Summary" })).toHaveClass("detail-tab-active");
expect(screen.queryByRole("combobox", { name: "Activity view" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Activity" }));
expect(activityViewLabels()).toEqual(["Live", "Feed", "Raw"]);
expect(screen.queryByRole("tablist", { name: "Activity views" })).not.toBeInTheDocument();
expect(screen.getByText("No agent output yet. Live messages from Planner, Executor, Reviewer, and Merger agents will appear here.")).toBeInTheDocument();
expect(screen.getAllByRole("form", { name: "Task refinement composer" })).toHaveLength(1);
expect(screen.queryByRole("form", { name: "Refinement request" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Chat" }));
expect(screen.getByRole("button", { name: "Chat" })).toHaveClass("detail-tab-active");
expect(screen.getByTestId("task-planner-chat-panel")).toBeInTheDocument();
expect(screen.queryByRole("form", { name: "Task activity composer" })).not.toBeInTheDocument();
expect(screen.queryByRole("form", { name: "Task refinement composer" })).not.toBeInTheDocument();
expect(screen.queryByRole("form", { name: "Steering comment" })).not.toBeInTheDocument();
expect(screen.queryByRole("form", { name: "Refinement request" })).not.toBeInTheDocument();
} finally {
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
window.dispatchEvent(new Event("resize"));
}
});
});