feat(FN-1601): merge fusion/fn-1601

This commit is contained in:
gsxdsm
2026-04-12 19:01:06 -07:00
parent 5c46af2d1c
commit cd73ecbf84
6 changed files with 148 additions and 162 deletions

View File

@@ -373,13 +373,13 @@ describe("tablet header controls", () => {
expect(screen.queryByTestId("project-selector-trigger")).toBeNull();
});
it("does not render split project button on tablet", () => {
it("does not render back to projects button on tablet", () => {
renderTabletHeader({
projects: [{ id: "1", name: "Project One", path: "/path/one", status: "active" as const }],
currentProject: { id: "1", name: "Project One", path: "/path/one", status: "active" as const },
onViewAllProjects: noop,
});
expect(screen.queryByTestId("header-projects-btn")).toBeNull();
expect(screen.queryByTestId("back-to-projects-btn")).toBeNull();
});
it("shows switch project in overflow menu when multiple projects on tablet", () => {

View File

@@ -927,66 +927,73 @@ describe("Header", () => {
});
});
describe("Projects button", () => {
describe("Back to All Projects button", () => {
const singleProject = [
{ id: "1", name: "Test Project", path: "/path/to/project", status: "active" as const },
];
it("renders Projects button when projects exist", () => {
it("renders Back to All Projects button when currentProject is set", () => {
renderHeader({
projects: singleProject,
currentProject: singleProject[0],
onViewAllProjects: noop,
}, "desktop");
expect(screen.getByTestId("header-projects-btn")).toBeDefined();
expect(screen.getByTestId("back-to-projects-btn")).toBeDefined();
});
it("calls onViewAllProjects when Projects button is clicked", () => {
it("calls onViewAllProjects when Back button is clicked", () => {
const onViewAllProjects = vi.fn();
renderHeader({
projects: singleProject,
currentProject: singleProject[0],
onViewAllProjects,
}, "desktop");
fireEvent.click(screen.getByTestId("header-projects-btn"));
fireEvent.click(screen.getByTestId("back-to-projects-btn"));
expect(onViewAllProjects).toHaveBeenCalled();
});
it("does not render Projects button when no projects", () => {
renderHeader({
projects: [],
onViewAllProjects: noop,
}, "desktop");
expect(screen.queryByTestId("header-projects-btn")).toBeNull();
});
it("does not render Projects button on mobile", () => {
it("does not render Back button when no currentProject", () => {
renderHeader({
projects: singleProject,
currentProject: null,
onViewAllProjects: noop,
}, "desktop");
expect(screen.queryByTestId("back-to-projects-btn")).toBeNull();
});
it("does not render Back button on mobile", () => {
renderHeader({
projects: singleProject,
currentProject: singleProject[0],
onViewAllProjects: noop,
}, "mobile");
expect(screen.queryByTestId("header-projects-btn")).toBeNull();
expect(screen.queryByTestId("back-to-projects-btn")).toBeNull();
});
it("does not render Projects button on tablet", () => {
it("does not render Back button on tablet", () => {
renderHeader({
projects: singleProject,
currentProject: singleProject[0],
onViewAllProjects: noop,
}, "tablet");
expect(screen.queryByTestId("header-projects-btn")).toBeNull();
expect(screen.queryByTestId("back-to-projects-btn")).toBeNull();
});
it("does not render Projects button when onViewAllProjects is not provided", () => {
it("does not render Back button when onViewAllProjects is not provided", () => {
renderHeader({
projects: singleProject,
currentProject: singleProject[0],
}, "desktop");
expect(screen.queryByTestId("header-projects-btn")).toBeNull();
expect(screen.queryByTestId("back-to-projects-btn")).toBeNull();
});
it("renders Projects button with correct title", () => {
it("renders Back button with correct title", () => {
renderHeader({
projects: singleProject,
currentProject: singleProject[0],
onViewAllProjects: noop,
}, "desktop");
expect(screen.getByTitle("View all projects")).toBeDefined();
expect(screen.getByTitle("Back to All Projects")).toBeDefined();
});
});

View File

@@ -18,11 +18,11 @@ const PROJECT_STATUS_CONFIG: Record<ProjectStatus, { color: string }> = {
};
/**
* SplitProjectButton - A two-stage button for project navigation.
* Left half: navigates to "all projects" view.
* Right half: opens a dropdown to select a specific project.
* ProjectSelector - A component for project navigation.
* Shows "Back to All Projects" button with ChevronLeft icon when currentProject is set.
* Shows project dropdown for switching when 2+ projects exist.
*/
function SplitProjectButton({
function ProjectSelector({
projects,
currentProject,
onViewAll,
@@ -69,23 +69,25 @@ function SplitProjectButton({
);
return (
<div className="split-project-btn" ref={dropdownRef}>
{/* Left half: projects view button */}
<button
className="split-project-btn__left"
onClick={onViewAll}
title="View all projects"
data-testid="header-projects-btn"
>
<Grid3X3 size={14} />
<span>{currentProject?.name || "Projects"}</span>
</button>
{/* Right half: dropdown arrow (only when multiple projects) */}
<div className="project-selector" ref={dropdownRef}>
{/* Back to All Projects button - shown when a project is selected */}
{currentProject && (
<button
className="header-back-button"
onClick={onViewAll}
title="Back to All Projects"
data-testid="back-to-projects-btn"
>
<ChevronLeft size={14} />
<span>Back to All Projects</span>
</button>
)}
{/* Project dropdown - shown when 2+ projects exist */}
{projects.length > 1 && (
<>
<div className="split-project-btn__divider" />
<button
className={`split-project-btn__right${isOpen ? " split-project-btn__right--open" : ""}`}
className={`project-selector-trigger${isOpen ? " project-selector-trigger--open" : ""}`}
onClick={() => setIsOpen((prev) => !prev)}
title="Switch project"
aria-label="Switch project"
@@ -93,11 +95,11 @@ function SplitProjectButton({
aria-haspopup="listbox"
data-testid="project-selector-trigger"
>
<ChevronDown size={12} className={`split-project-btn__chevron${isOpen ? " split-project-btn__chevron--open" : ""}`} />
<ChevronDown size={12} className={`project-selector-chevron${isOpen ? " project-selector-chevron--open" : ""}`} />
</button>
{isOpen && (
<div
className="split-project-btn__dropdown"
className="project-selector-dropdown"
role="listbox"
aria-label="Select project"
data-testid="project-selector-dropdown"
@@ -108,22 +110,22 @@ function SplitProjectButton({
return (
<button
key={project.id}
className={`split-project-btn__item${isCurrent ? " split-project-btn__item--current" : ""}`}
className={`project-selector-item${isCurrent ? " project-selector-item--current" : ""}`}
onClick={() => handleSelectProject(project)}
role="option"
aria-selected={isCurrent}
>
<span
className="split-project-btn__item-dot"
className="project-selector-dot"
style={{ backgroundColor: statusColor || "var(--text-muted)" }}
/>
<div className="split-project-btn__item-info">
<span className="split-project-btn__item-name">{project.name}</span>
<span className="split-project-btn__item-path">
<div className="project-selector-info">
<span className="project-selector-name">{project.name}</span>
<span className="project-selector-path">
{project.path.split("/").slice(-2).join("/")}
</span>
</div>
{isCurrent && <Check size={14} className="split-project-btn__item-check" />}
{isCurrent && <Check size={14} className="project-selector-check" />}
</button>
);
})}
@@ -436,9 +438,9 @@ export function Header({
<h1 className="logo">Fusion</h1>
</div>
{/* Split-button Project Selector - left: projects view, right: project dropdown (desktop only) */}
{/* Project Selector - Back button when project selected, dropdown when 2+ projects (desktop only) */}
{!isCompact && projects.length >= 1 && onViewAllProjects && (
<SplitProjectButton
<ProjectSelector
projects={projects}
currentProject={currentProject ?? null}
onViewAll={onViewAllProjects}

View File

@@ -837,7 +837,8 @@ describe("Header", () => {
onViewAllProjects={vi.fn()}
/>
);
expect(screen.queryByTestId("header-projects-btn")).toBeNull();
// On mobile/tablet, back button is hidden (shown in overflow menu instead)
expect(screen.queryByTestId("back-to-projects-btn")).toBeNull();
});
it("shows switch project item in overflow menu on mobile when multiple projects", () => {
@@ -999,17 +1000,25 @@ describe("Header", () => {
});
});
// ── Split Project Button ────────────────────────────────────
// ── Project Selector ────────────────────────────────────
it("shows split project button when 1+ projects provided with onViewAllProjects", () => {
it("shows back to projects button when currentProject is set", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(<Header projects={projects} onViewAllProjects={vi.fn()} />);
expect(screen.getByTestId("header-projects-btn")).toBeDefined();
render(<Header projects={projects} currentProject={projects[0]} onViewAllProjects={vi.fn()} />);
expect(screen.getByTestId("back-to-projects-btn")).toBeDefined();
});
it("renders split project button within header-left when projects exist", () => {
it("does not show back button when no currentProject", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(<Header projects={projects} currentProject={null} onViewAllProjects={vi.fn()} />);
expect(screen.queryByTestId("back-to-projects-btn")).toBeNull();
});
it("renders project selector within header-left when projects exist", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
@@ -1022,25 +1031,25 @@ describe("Header", () => {
onViewAllProjects={vi.fn()}
/>
);
// SplitProjectButton should be inside header-left
// ProjectSelector should be inside header-left
const headerLeft = container.querySelector(".header-left");
expect(headerLeft).not.toBeNull();
const splitBtn = headerLeft!.querySelector(".split-project-btn");
expect(splitBtn).not.toBeNull();
expect(splitBtn!.querySelector("[data-testid='header-projects-btn']")).not.toBeNull();
const projectSelector = headerLeft!.querySelector(".project-selector");
expect(projectSelector).not.toBeNull();
expect(projectSelector!.querySelector("[data-testid='back-to-projects-btn']")).not.toBeNull();
});
it("does not show split project button when no projects", () => {
it("does not show project selector when no projects", () => {
const { container } = render(<Header projects={[]} />);
expect(container.querySelector(".split-project-btn")).toBeNull();
expect(container.querySelector(".project-selector")).toBeNull();
});
it("does not show split project button without onViewAllProjects", () => {
it("does not show project selector without onViewAllProjects", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
const { container } = render(<Header projects={projects} />);
expect(container.querySelector(".split-project-btn")).toBeNull();
expect(container.querySelector(".project-selector")).toBeNull();
});
it("shows project dropdown arrow only when 2+ projects", () => {
@@ -1048,7 +1057,7 @@ describe("Header", () => {
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(<Header projects={projects} onViewAllProjects={vi.fn()} />);
render(<Header projects={projects} currentProject={projects[0]} onViewAllProjects={vi.fn()} />);
expect(screen.getByTestId("project-selector-trigger")).toBeDefined();
});
@@ -1056,11 +1065,11 @@ describe("Header", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(<Header projects={projects} onViewAllProjects={vi.fn()} />);
render(<Header projects={projects} currentProject={projects[0]} onViewAllProjects={vi.fn()} />);
expect(screen.queryByTestId("project-selector-trigger")).toBeNull();
});
it("left button calls onViewAllProjects when clicked", () => {
it("back button calls onViewAllProjects when clicked", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
@@ -1073,7 +1082,7 @@ describe("Header", () => {
onViewAllProjects={onViewAllProjects}
/>
);
fireEvent.click(screen.getByTestId("header-projects-btn"));
fireEvent.click(screen.getByTestId("back-to-projects-btn"));
expect(onViewAllProjects).toHaveBeenCalled();
});
@@ -1099,7 +1108,7 @@ describe("Header", () => {
expect(onSelectProject).toHaveBeenCalledWith(projects[1]);
});
it("shows current project name in left button", () => {
it("shows 'Back to All Projects' text in back button", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
@@ -1113,25 +1122,9 @@ describe("Header", () => {
/>
);
// Left button should show current project name
const leftBtn = screen.getByTestId("header-projects-btn");
expect(leftBtn.textContent).toContain("Project One");
});
it("shows 'Projects' label when no current project", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(
<Header
projects={projects}
currentProject={null}
onViewAllProjects={vi.fn()}
/>
);
const leftBtn = screen.getByTestId("header-projects-btn");
expect(leftBtn.textContent).toContain("Projects");
// Back button should show "Back to All Projects"
const backBtn = screen.getByTestId("back-to-projects-btn");
expect(backBtn.textContent).toContain("Back to All Projects");
});
// ── Modal Overlay Visibility ──────────────────────────────────

View File

@@ -111,7 +111,7 @@ describe("MultiProjectFlow", () => {
expect(storage[taskViewKey]).toBe("board");
});
describe("Projects button navigation", () => {
describe("Back to All Projects button navigation", () => {
const singleProject: ProjectInfo = {
id: "proj_1",
name: "Solo Project",
@@ -122,7 +122,7 @@ describe("MultiProjectFlow", () => {
updatedAt: "2026-01-01T00:00:00.000Z",
};
it("shows Projects button and navigates to overview on click", () => {
it("shows Back to All Projects button and navigates to overview on click", () => {
mockDesktopMatchMedia();
let viewMode: "overview" | "project" = "project";
@@ -139,21 +139,22 @@ describe("MultiProjectFlow", () => {
onToggleGlobalPause={noop}
onToggleEnginePause={noop}
projects={[singleProject]}
currentProject={singleProject}
onViewAllProjects={handleViewAllProjects}
/>
);
// The Projects button should be visible for single-project users
const projectsBtn = screen.getByTestId("header-projects-btn");
expect(projectsBtn).toBeDefined();
// The Back to All Projects button should be visible when currentProject is set
const backBtn = screen.getByTestId("back-to-projects-btn");
expect(backBtn).toBeDefined();
// Clicking should trigger navigation to overview
fireEvent.click(projectsBtn);
fireEvent.click(backBtn);
expect(handleViewAllProjects).toHaveBeenCalled();
expect(viewMode).toBe("overview");
});
it("shows Projects button with current project name when currentProject is set", () => {
it("shows Back to All Projects button text when currentProject is set", () => {
mockDesktopMatchMedia();
render(
@@ -170,10 +171,10 @@ describe("MultiProjectFlow", () => {
/>
);
// The split button should show the current project name
const projectsBtn = screen.getByTestId("header-projects-btn");
expect(projectsBtn).toBeDefined();
expect(projectsBtn.textContent).toContain("Solo Project");
// The back button should show "Back to All Projects"
const backBtn = screen.getByTestId("back-to-projects-btn");
expect(backBtn).toBeDefined();
expect(backBtn.textContent).toContain("Back to All Projects");
});
});
});

View File

@@ -6476,8 +6476,8 @@ body {
padding-top: max(var(--space-md), env(safe-area-inset-top, 0px));
}
/* Hide split project button on mobile (belt-and-suspenders with conditional rendering) */
.split-project-btn {
/* Hide project selector on mobile (belt-and-suspenders with conditional rendering) */
.project-selector {
display: none;
}
@@ -6947,8 +6947,8 @@ body {
padding: var(--space-md) var(--space-lg);
}
/* Hide split project button on tablet (controlled by React rendering too) */
.split-project-btn {
/* Hide project selector on tablet (controlled by React rendering too) */
.project-selector {
display: none;
}
@@ -18332,79 +18332,70 @@ html .column.drag-over * {
}
/* === Split Project Button === */
.split-project-btn {
position: relative;
display: inline-flex;
align-items: stretch;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--surface);
overflow: visible;
transition: border-color var(--transition-fast);
}
.split-project-btn:hover {
border-color: var(--text-dim);
}
.split-project-btn__left {
/* === Header Back Button === */
.header-back-button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: transparent;
border: none;
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
white-space: nowrap;
border-radius: var(--radius-md) 0 0 var(--radius-md);
}
.split-project-btn__left:hover {
.header-back-button:hover {
background: var(--card-hover);
color: var(--text);
border-color: var(--text-dim);
}
.split-project-btn__divider {
width: 1px;
background: var(--border);
align-self: stretch;
margin: 4px 0;
/* === Project Selector === */
.project-selector {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
overflow: visible;
}
.split-project-btn__right {
.project-selector-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 6px;
background: transparent;
border: none;
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-muted);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
border-radius: 0 var(--radius-md) var(--radius-md) 0;
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
}
.split-project-btn__right:hover {
.project-selector-trigger:hover {
background: var(--card-hover);
color: var(--text);
border-color: var(--text-dim);
}
.split-project-btn__right--open {
.project-selector-trigger--open {
color: var(--text);
border-color: var(--text-dim);
}
.split-project-btn__chevron {
.project-selector-chevron {
transition: transform var(--transition-fast);
}
.split-project-btn__chevron--open {
.project-selector-chevron--open {
transform: rotate(180deg);
}
.split-project-btn__dropdown {
.project-selector-dropdown {
position: absolute;
top: calc(100% + 8px);
left: 0;
@@ -18418,7 +18409,7 @@ html .column.drag-over * {
box-shadow: var(--shadow-lg);
}
.split-project-btn__item {
.project-selector-item {
width: 100%;
display: flex;
align-items: center;
@@ -18434,22 +18425,22 @@ html .column.drag-over * {
transition: background var(--transition-fast);
}
.split-project-btn__item:hover {
.project-selector-item:hover {
background: var(--card-hover);
}
.split-project-btn__item--current {
.project-selector-item--current {
background: var(--card-hover);
}
.split-project-btn__item-dot {
.project-selector-dot {
flex-shrink: 0;
width: 8px;
height: 8px;
border-radius: 50%;
}
.split-project-btn__item-info {
.project-selector-info {
flex: 1;
min-width: 0;
display: flex;
@@ -18457,14 +18448,14 @@ html .column.drag-over * {
gap: 2px;
}
.split-project-btn__item-name {
.project-selector-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.split-project-btn__item-path {
.project-selector-path {
font-size: 11px;
color: var(--text-muted);
overflow: hidden;
@@ -18472,33 +18463,25 @@ html .column.drag-over * {
white-space: nowrap;
}
.split-project-btn__item-check {
.project-selector-check {
flex-shrink: 0;
color: var(--todo);
}
/* Light theme overrides for split project button */
[data-theme="light"] .split-project-btn {
/* Light theme overrides for project selector */
[data-theme="light"] .project-selector-trigger:hover {
background: var(--card-hover);
}
[data-theme="light"] .project-selector-dropdown {
background: var(--surface);
}
[data-theme="light"] .split-project-btn__left:hover {
[data-theme="light"] .project-selector-item:hover {
background: var(--card-hover);
}
[data-theme="light"] .split-project-btn__right:hover {
background: var(--card-hover);
}
[data-theme="light"] .split-project-btn__dropdown {
background: var(--surface);
}
[data-theme="light"] .split-project-btn__item:hover {
background: var(--card-hover);
}
[data-theme="light"] .split-project-btn__item--current {
[data-theme="light"] .project-selector-item--current {
background: rgba(88, 166, 255, 0.1);
}