feat(FN-4076): tighten mobile agents header and popup, add compact planning
Merges FN-4076: tightens the mobile Agents header and aligns the controls popup on smaller screens, while also introducing compact planning breakdown task creation with preserved payload coverage — tests for scoped and empty-generated planning payloads were added alongside fixes to the legacy API an Fusion-Task-Id: FN-4076
This commit is contained in:
@@ -457,7 +457,7 @@ describe("AgentsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps New Agent directly accessible while controls live in popup", async () => {
|
||||
it("keeps New Agent directly accessible on desktop while controls live in popup", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "New Agent" })).toBeTruthy();
|
||||
@@ -466,11 +466,23 @@ describe("AgentsView", () => {
|
||||
await openControlsPanel();
|
||||
expect(screen.getByLabelText("Filter agents by state")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Show system agents")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Import" })).toBeTruthy();
|
||||
expect(screen.getAllByRole("button", { name: "Import" }).length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole("slider", { name: "Heartbeat Speed" })).toBeTruthy();
|
||||
expect(screen.getByLabelText("Heartbeat speed preset")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("moves import and new-agent actions into the controls popup on mobile", async () => {
|
||||
mockViewportMode.mockReturnValue("mobile");
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Import" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "New Agent" })).toBeNull();
|
||||
|
||||
await openControlsPanel();
|
||||
expect(screen.getByRole("button", { name: "Import" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "New Agent" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes controls popup on Escape and outside click", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
const trigger = await openControlsPanel();
|
||||
@@ -1397,6 +1409,18 @@ describe("AgentsView", () => {
|
||||
expect(css).toContain(".org-chart-children > .org-chart-node::before");
|
||||
});
|
||||
|
||||
it("keeps a compact mobile Agents label visible, anchors the controls popup to the action row, and expands view toggles to 36px touch targets", () => {
|
||||
const css = loadAllAppCss();
|
||||
expect(css).toContain(".agents-view-primary-actions {\n position: relative;");
|
||||
expect(css).toContain(".agent-controls-panel {\n position: absolute;\n top: calc(100% + var(--space-sm));\n right: 0;");
|
||||
expect(css).toContain(".agents-view-title h2 {\n display: block;\n font-size: var(--space-md);");
|
||||
expect(css).toContain(".agent-controls-mobile-actions {");
|
||||
expect(css).toContain(".agent-controls-mobile-actions .btn {");
|
||||
expect(css).toContain(".agents-view-controls .view-toggle .view-toggle-btn {");
|
||||
expect(css).toContain("min-width: calc(var(--space-lg) * 2 + var(--space-xs));");
|
||||
expect(css).toContain("min-height: calc(var(--space-lg) * 2 + var(--space-xs));");
|
||||
});
|
||||
|
||||
it("switches org chart to vertical layout mode when estimated width exceeds viewport", async () => {
|
||||
const clientWidthSpy = vi.spyOn(window.HTMLElement.prototype, "clientWidth", "get").mockReturnValue(320);
|
||||
mockFetchOrgTree.mockResolvedValue(orgTree);
|
||||
|
||||
@@ -935,7 +935,262 @@ describe("PlanningModeModal", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockCreateTasksFromPlanning).toHaveBeenCalledWith(
|
||||
"session-breakdown-priority",
|
||||
[expect.objectContaining({ id: "subtask-1", priority: "urgent" })],
|
||||
[{ id: "subtask-1", priority: "urgent" }],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("sends only edited breakdown fields when creating tasks", async () => {
|
||||
const resumedSummary: PlanningSummary = {
|
||||
title: "Resume-to-breakdown-compact",
|
||||
description: "Recovered summary for compact breakdown",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Implement", "Verify"],
|
||||
};
|
||||
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "session-breakdown-compact",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Resume-to-breakdown-compact",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Recover and break down compactly" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(resumedSummary),
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
mockStartPlanningBreakdown.mockResolvedValueOnce({
|
||||
sessionId: "session-breakdown-compact",
|
||||
subtasks: [
|
||||
{
|
||||
id: "subtask-1",
|
||||
title: "First subtask",
|
||||
description: "First description",
|
||||
suggestedSize: "M",
|
||||
dependsOn: [],
|
||||
},
|
||||
{
|
||||
id: "subtask-2",
|
||||
title: "Second subtask",
|
||||
description: "Second description",
|
||||
suggestedSize: "S",
|
||||
dependsOn: ["subtask-1"],
|
||||
},
|
||||
],
|
||||
});
|
||||
mockCreateTasksFromPlanning.mockResolvedValueOnce({ tasks: [] });
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
resumeSessionId="session-breakdown-compact"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Break into Tasks" })).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Break into Tasks" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Create Tasks" })).toBeDefined();
|
||||
});
|
||||
|
||||
const firstSubtask = screen.getByTestId("subtask-item-0");
|
||||
fireEvent.change(within(firstSubtask).getAllByRole("textbox")[0]!, {
|
||||
target: { value: "Edited first subtask" },
|
||||
});
|
||||
const secondSubtask = screen.getByTestId("subtask-item-1");
|
||||
fireEvent.change(within(secondSubtask).getAllByRole("textbox")[1]!, {
|
||||
target: { value: "Edited second description" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Tasks" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateTasksFromPlanning).toHaveBeenCalledWith(
|
||||
"session-breakdown-compact",
|
||||
[
|
||||
{ id: "subtask-1", title: "Edited first subtask" },
|
||||
{ id: "subtask-2", description: "Edited second description" },
|
||||
],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("includes client-added subtasks in the compact create-tasks payload", async () => {
|
||||
const resumedSummary: PlanningSummary = {
|
||||
title: "Resume-to-breakdown-add-subtask",
|
||||
description: "Recovered summary for added subtask",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Implement"],
|
||||
};
|
||||
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "session-breakdown-add-subtask",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Resume-to-breakdown-add-subtask",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Recover and add a subtask" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(resumedSummary),
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
mockStartPlanningBreakdown.mockResolvedValueOnce({
|
||||
sessionId: "session-breakdown-add-subtask",
|
||||
subtasks: [
|
||||
{
|
||||
id: "subtask-1",
|
||||
title: "Existing subtask",
|
||||
description: "Existing description",
|
||||
suggestedSize: "M",
|
||||
dependsOn: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
mockCreateTasksFromPlanning.mockResolvedValueOnce({ tasks: [] });
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
resumeSessionId="session-breakdown-add-subtask"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Break into Tasks" })).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Break into Tasks" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Add subtask" })).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add subtask" }));
|
||||
const addedSubtask = screen.getByTestId("subtask-item-1");
|
||||
const addedTextboxes = within(addedSubtask).getAllByRole("textbox");
|
||||
fireEvent.change(addedTextboxes[0]!, { target: { value: "Rollout follow-up" } });
|
||||
fireEvent.change(addedTextboxes[1]!, { target: { value: "Prepare rollout notes" } });
|
||||
fireEvent.change(within(addedSubtask).getByLabelText("Size"), { target: { value: "S" } });
|
||||
fireEvent.change(within(addedSubtask).getByLabelText("Priority"), { target: { value: "high" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Tasks" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateTasksFromPlanning).toHaveBeenCalledWith(
|
||||
"session-breakdown-add-subtask",
|
||||
[
|
||||
{ id: "subtask-1" },
|
||||
{
|
||||
id: "subtask-2",
|
||||
title: "Rollout follow-up",
|
||||
description: "Prepare rollout notes",
|
||||
suggestedSize: "S",
|
||||
priority: "high",
|
||||
dependsOn: [],
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("omits removed generated subtasks from the compact create-tasks payload", async () => {
|
||||
const resumedSummary: PlanningSummary = {
|
||||
title: "Resume-to-breakdown-remove-subtask",
|
||||
description: "Recovered summary for removed subtask",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Implement", "Verify"],
|
||||
};
|
||||
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "session-breakdown-remove-subtask",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Resume-to-breakdown-remove-subtask",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Recover and remove a subtask" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(resumedSummary),
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
mockStartPlanningBreakdown.mockResolvedValueOnce({
|
||||
sessionId: "session-breakdown-remove-subtask",
|
||||
subtasks: [
|
||||
{
|
||||
id: "subtask-1",
|
||||
title: "Existing subtask",
|
||||
description: "Existing description",
|
||||
suggestedSize: "M",
|
||||
dependsOn: [],
|
||||
},
|
||||
{
|
||||
id: "subtask-2",
|
||||
title: "Generated follow-up",
|
||||
description: "Generated follow-up description",
|
||||
suggestedSize: "S",
|
||||
dependsOn: ["subtask-1"],
|
||||
},
|
||||
],
|
||||
});
|
||||
mockCreateTasksFromPlanning.mockResolvedValueOnce({ tasks: [] });
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
resumeSessionId="session-breakdown-remove-subtask"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Break into Tasks" })).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Break into Tasks" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Create Tasks" })).toBeDefined();
|
||||
});
|
||||
|
||||
const secondSubtask = screen.getByTestId("subtask-item-1");
|
||||
fireEvent.click(within(secondSubtask).getByRole("button", { name: "Remove" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Tasks" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateTasksFromPlanning).toHaveBeenCalledWith(
|
||||
"session-breakdown-remove-subtask",
|
||||
[{ id: "subtask-1" }],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1141,6 +1141,19 @@ describe("PlanningModeModal", () => {
|
||||
|
||||
fireEvent.change(sizeSelect, { target: { value: "L" } });
|
||||
expect(sizeSelect.value).toBe("L");
|
||||
|
||||
fireEvent.click(screen.getByText("Create Tasks"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateTasksFromPlanning).toHaveBeenCalledWith(
|
||||
"session-123",
|
||||
[
|
||||
{ id: "subtask-1", suggestedSize: "L" },
|
||||
{ id: "subtask-2" },
|
||||
],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user