feat(FN-4224): tighten github tracking header to single-row layout in task

Restyles the GitHub tracking header in TaskDetailModal to a compact single-row layout with tighter spacing, adds test coverage for the header layout, and includes a regression verification pass alongside a changeset documenting the change.

Fusion-Task-Id: FN-4224
This commit is contained in:
Fusion
2026-05-12 17:30:25 -07:00
committed by gsxdsm
parent a136dd340c
commit 4a6b56136b
7 changed files with 229 additions and 10 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Auto-open newly created chat rooms in the dashboard so successful room creation immediately reveals the new thread, including collapsing the mobile sidebar.

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Tighten the Task Detail modal GitHub tracking header into a compact single-row summary with the inline Enable action and disclosure toggle staying aligned across desktop and mobile layouts.

View File

@@ -2867,7 +2867,13 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
existingRoomNames={rooms.rooms.map((room) => room.name)} existingRoomNames={rooms.rooms.map((room) => room.name)}
onCreate={async (draft) => { onCreate={async (draft) => {
await rooms.createRoom({ name: draft.name, memberAgentIds: draft.memberAgentIds }); await rooms.createRoom({ name: draft.name, memberAgentIds: draft.memberAgentIds });
if (chatScope !== "rooms") {
setChatScope("rooms");
}
setCreateRoomOpen(false); setCreateRoomOpen(false);
if (isMobile) {
setSidebarVisible(false);
}
}} }}
/> />
)} )}

View File

@@ -439,6 +439,7 @@
.detail-github-tracking-enable { .detail-github-tracking-enable {
margin-left: auto; margin-left: auto;
align-self: flex-start; align-self: flex-start;
flex: 0 0 auto;
padding-block: calc(var(--space-xs) / 2); padding-block: calc(var(--space-xs) / 2);
padding-inline: var(--space-sm); padding-inline: var(--space-sm);
line-height: 1.2; line-height: 1.2;
@@ -488,12 +489,38 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--card); background: var(--card);
padding: var(--space-sm) var(--space-md); padding: var(--space-xs) var(--space-md);
}
.detail-github-tracking-section .detail-source-header {
flex-wrap: nowrap;
align-items: center;
min-width: 0;
}
.detail-github-tracking-section .detail-source-summary {
flex: 1 1 auto;
flex-wrap: nowrap;
min-width: 0;
}
.detail-github-tracking-section .detail-source-empty {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-github-tracking-section .detail-source-toggle {
flex: 0 0 auto;
}
.detail-github-tracking-section .detail-github-tracking-enable {
align-self: center;
} }
.detail-github-tracking-content { .detail-github-tracking-content {
margin-top: var(--space-sm); margin-top: var(--space-xs);
padding-top: var(--space-sm); padding-top: var(--space-xs);
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
@@ -1599,17 +1626,23 @@
flex: 1 1 100%; flex: 1 1 100%;
} }
.detail-github-tracking-section .detail-source-summary { .detail-github-tracking-section .detail-source-header {
flex: 1 1 auto; flex-wrap: nowrap;
min-width: 0; min-width: 0;
} }
.detail-github-tracking-enable { .detail-github-tracking-section .detail-source-summary {
flex: 1 1 auto;
flex-wrap: nowrap;
min-width: 0;
}
.detail-github-tracking-section .detail-github-tracking-enable {
margin-left: auto; margin-left: auto;
justify-content: center; justify-content: center;
} }
.detail-source-header .detail-source-toggle { .detail-github-tracking-section .detail-source-header .detail-source-toggle {
margin-left: 0; margin-left: 0;
} }

View File

@@ -4,6 +4,7 @@
*/ */
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { useState } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { userEvent } from "@testing-library/user-event"; import { userEvent } from "@testing-library/user-event";
import { ChatView } from "../ChatView"; import { ChatView } from "../ChatView";
@@ -181,6 +182,50 @@ function setupMockRooms(overrides: Partial<UseChatRoomsResult> = {}) {
mockUseChatRooms.mockReturnValue(state); mockUseChatRooms.mockReturnValue(state);
} }
function createRoomFixture(name: string) {
return {
id: `room-${name}`,
projectId: "proj-123",
slug: name,
name,
createdAt: "2026-05-12T00:00:00.000Z",
updatedAt: "2026-05-12T00:00:00.000Z",
};
}
function setupStatefulCreateRoomMock(options?: { createRejects?: boolean }) {
const createRoom = vi.fn();
mockUseChatRooms.mockImplementation(() => {
const [roomsState, setRoomsState] = useState<UseChatRoomsResult["rooms"]>([]);
const [activeRoom, setActiveRoom] = useState<UseChatRoomsResult["activeRoom"]>(null);
return {
...defaultRoomsState,
rooms: roomsState,
activeRoom,
activeRoomMembers: activeRoom
? [{ roomId: activeRoom.id, agentId: "agent-001", role: "member", addedAt: "2026-05-12T00:00:00.000Z" }]
: [],
createRoom: async ({ name, memberAgentIds }) => {
createRoom({ name, memberAgentIds });
if (options?.createRejects) {
throw new Error("Failed to create room.");
}
const nextRoom = createRoomFixture(name);
setRoomsState((previous) => [...previous, nextRoom]);
setActiveRoom(nextRoom);
return nextRoom;
},
selectRoom: (roomId) => {
setActiveRoom(roomsState.find((room) => room.id === roomId) ?? null);
},
} satisfies UseChatRoomsResult;
});
return { createRoom };
}
function ensureMatchMedia() { function ensureMatchMedia() {
if (!window.matchMedia) { if (!window.matchMedia) {
Object.defineProperty(window, "matchMedia", { Object.defineProperty(window, "matchMedia", {
@@ -208,7 +253,10 @@ function mockViewportMode(mode: "mobile" | "desktop") {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
localStorage.clear();
_resetInitialViewportHeight();
setupMockRooms(); setupMockRooms();
mockViewportMode("desktop");
mockFetchDiscoveredSkills.mockResolvedValue([]); mockFetchDiscoveredSkills.mockResolvedValue([]);
mockCreateObjectURL.mockImplementation((file: File) => `blob:${file.name}`); mockCreateObjectURL.mockImplementation((file: File) => `blob:${file.name}`);
Object.defineProperty(URL, "createObjectURL", { value: mockCreateObjectURL, writable: true }); Object.defineProperty(URL, "createObjectURL", { value: mockCreateObjectURL, writable: true });
@@ -222,7 +270,8 @@ beforeEach(() => {
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
localStorage.removeItem("fusion:chat-scope"); localStorage.clear();
_resetInitialViewportHeight();
}); });
describe("ChatView", () => { describe("ChatView", () => {
@@ -2258,6 +2307,7 @@ describe("ChatView", () => {
const toggle = screen.getByTestId("chat-thread-render-toggle"); const toggle = screen.getByTestId("chat-thread-render-toggle");
const providerIcon = identity.querySelector(".provider-icon"); const providerIcon = identity.querySelector(".provider-icon");
const modelTag = identity.querySelector(".chat-model-tag"); const modelTag = identity.querySelector(".chat-model-tag");
const newChatButton = screen.getByTestId("chat-thread-new-chat-btn");
expect(header).toBeInTheDocument(); expect(header).toBeInTheDocument();
expect(providerIcon).toBeInTheDocument(); expect(providerIcon).toBeInTheDocument();
@@ -2265,7 +2315,8 @@ describe("ChatView", () => {
expect(modelTag).toBeInTheDocument(); expect(modelTag).toBeInTheDocument();
expect(modelTag).toHaveTextContent("Claude Sonnet 4.5"); expect(modelTag).toHaveTextContent("Claude Sonnet 4.5");
expect(toggle).toBeInTheDocument(); expect(toggle).toBeInTheDocument();
expect(header?.children[header.children.length - 1]).toBe(toggle); expect(header?.children[header.children.length - 2]).toBe(toggle);
expect(header?.children[header.children.length - 1]).toBe(newChatButton);
expect(document.querySelectorAll(".chat-thread-header .chat-model-tag")).toHaveLength(1); expect(document.querySelectorAll(".chat-thread-header .chat-model-tag")).toHaveLength(1);
}); });
@@ -2794,6 +2845,82 @@ describe("ChatView sidebar structure", () => {
}); });
}); });
describe("room creation", () => {
it("opens the newly created room and collapses the mobile sidebar on success", async () => {
const viewportSpy = mockViewportMode("mobile");
const { createRoom } = setupStatefulCreateRoomMock();
setupMockChat({ sessions: [], filteredSessions: [] });
localStorage.setItem("fusion:chat-scope", "rooms");
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-create-room-btn"));
const dialog = await screen.findByRole("dialog", { name: "Create room" });
await userEvent.type(within(dialog).getByLabelText("Room name"), "newroom");
await userEvent.click(within(screen.getByTestId("create-room-member-list")).getByText("Alpha"));
await userEvent.click(within(dialog).getByRole("button", { name: "Create room" }));
await waitFor(() => {
expect(createRoom).toHaveBeenCalledWith({ name: "newroom", memberAgentIds: ["agent-001"] });
});
await waitFor(() => {
expect(document.querySelector(".chat-sidebar")).toHaveClass("chat-sidebar--hidden");
});
expect(screen.queryByRole("dialog", { name: "Create room" })).toBeNull();
expect(within(document.querySelector(".chat-room-thread-header") as HTMLElement).getByText("#newroom")).toBeInTheDocument();
viewportSpy.mockRestore();
});
it("opens the newly created room on desktop without hiding the sidebar", async () => {
const viewportSpy = mockViewportMode("desktop");
const { createRoom } = setupStatefulCreateRoomMock();
setupMockChat({ sessions: [], filteredSessions: [] });
localStorage.setItem("fusion:chat-scope", "rooms");
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-create-room-btn"));
const dialog = await screen.findByRole("dialog", { name: "Create room" });
await userEvent.type(within(dialog).getByLabelText("Room name"), "newroom");
await userEvent.click(within(screen.getByTestId("create-room-member-list")).getByText("Alpha"));
await userEvent.click(within(dialog).getByRole("button", { name: "Create room" }));
await waitFor(() => {
expect(createRoom).toHaveBeenCalledWith({ name: "newroom", memberAgentIds: ["agent-001"] });
});
expect(document.querySelector(".chat-sidebar")).not.toHaveClass("chat-sidebar--hidden");
expect(screen.queryByRole("dialog", { name: "Create room" })).toBeNull();
expect(within(document.querySelector(".chat-room-thread-header") as HTMLElement).getByText("#newroom")).toBeInTheDocument();
viewportSpy.mockRestore();
});
it("keeps the modal open and sidebar visible when room creation fails", async () => {
const viewportSpy = mockViewportMode("mobile");
const { createRoom } = setupStatefulCreateRoomMock({ createRejects: true });
setupMockChat({ sessions: [], filteredSessions: [] });
localStorage.setItem("fusion:chat-scope", "rooms");
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-create-room-btn"));
const dialog = await screen.findByRole("dialog", { name: "Create room" });
await userEvent.type(within(dialog).getByLabelText("Room name"), "newroom");
await userEvent.click(within(screen.getByTestId("create-room-member-list")).getByText("Alpha"));
await userEvent.click(within(dialog).getByRole("button", { name: "Create room" }));
await waitFor(() => {
expect(createRoom).toHaveBeenCalledWith({ name: "newroom", memberAgentIds: ["agent-001"] });
});
expect(screen.getByRole("dialog", { name: "Create room" })).toBeInTheDocument();
expect(document.querySelector(".chat-sidebar")).not.toHaveClass("chat-sidebar--hidden");
expect(screen.queryByText("#newroom")).toBeNull();
viewportSpy.mockRestore();
});
});
describe("Direct/Rooms scope toggle", () => { describe("Direct/Rooms scope toggle", () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { loadAllAppCss } from "../../test/cssFixture";
import { TaskDetailModal } from "../TaskDetailModal";
import { makeTask, noop, noopDelete, noopMerge, noopMove, noopOpenDetail, setupTaskDetailModalHooks } from "./TaskDetailModal.test-helpers";
setupTaskDetailModalHooks();
describe("FN-4224 GitHub tracking header layout", () => {
it("keeps the summary, enable action, and disclosure toggle on one row across desktop and mobile CSS", () => {
render(
<TaskDetailModal
task={makeTask({
column: "todo",
githubTracking: { enabled: false },
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("GitHub tracking")).toBeInTheDocument();
expect(screen.getByText("Tracking is currently disabled")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Enable GitHub tracking" })).toHaveTextContent("Enable");
expect(screen.getByRole("button", { name: "Expand GitHub tracking details" })).toBeInTheDocument();
const css = loadAllAppCss();
expect(css).toMatch(
/\.detail-github-tracking-section\s+\.detail-source-header\s*\{[^}]*flex-wrap:\s*nowrap;[^}]*align-items:\s*center;[^}]*min-width:\s*0;/,
);
expect(css).toMatch(
/\.detail-github-tracking-section\s+\.detail-source-summary\s*\{[^}]*flex:\s*1 1 auto;[^}]*flex-wrap:\s*nowrap;[^}]*min-width:\s*0;/,
);
expect(css).toMatch(
/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*?\.detail-github-tracking-section\s+\.detail-source-header\s*\{[^}]*flex-wrap:\s*nowrap;[^}]*min-width:\s*0;[^}]*\}[\s\S]*?\.detail-github-tracking-section\s+\.detail-source-summary\s*\{[^}]*flex:\s*1 1 auto;[^}]*flex-wrap:\s*nowrap;[^}]*min-width:\s*0;[^}]*\}/,
);
});
});

View File

@@ -11,7 +11,7 @@ const qualityAppTests = [
"app/api/**/*.test.ts", "app/api/**/*.test.ts",
// Representative workflow/component coverage. Exhaustive modal/view suites // Representative workflow/component coverage. Exhaustive modal/view suites
// stay available in the full `dashboard-app` project. // stay available in the full `dashboard-app` project.
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,AuthTokenRecoveryDialog,Board,board-mobile-view-switch,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,ResearchView,StashRecoveryView,TaskCard,TaskChangesTab,TaskComments,TaskDocumentsTab,TaskForm,ThemeSelectorSwatchContract,WorkflowResultsTab}.test.tsx", "app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,AuthTokenRecoveryDialog,Board,board-mobile-view-switch,ChatView,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,ResearchView,StashRecoveryView,TaskCard,TaskChangesTab,TaskComments,TaskDetailModal.github-tracking-header,TaskDocumentsTab,TaskForm,ThemeSelectorSwatchContract,WorkflowResultsTab}.test.tsx",
// Hooks and utilities are fast, user-visible state/formatting behavior. // Hooks and utilities are fast, user-visible state/formatting behavior.
"app/context/**/*.test.tsx", "app/context/**/*.test.tsx",
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}", "app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",