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

This commit is contained in:
gsxdsm
2026-04-14 10:13:53 -07:00
parent 6e8bf267a7
commit 0dffdec04c
6 changed files with 434 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
"@gsxdsm/fusion": patch
---
Add mobile-first project switch affordance in the dashboard header. Users with 2+ projects on mobile viewports now see a project dropdown icon next to the Fusion logo, enabling direct project switching without opening the overflow menu. The dropdown supports direct project selection, shows current project highlighting, and closes on outside click or Escape. Desktop and tablet behavior remains unchanged.

View File

@@ -399,6 +399,19 @@ describe("tablet header controls", () => {
expect(btn.textContent).toContain("Projects");
});
it("does not render mobile project switch trigger on tablet", () => {
const projects = [
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
];
renderTabletHeader({
projects,
currentProject: projects[0],
onSelectProject: vi.fn(),
});
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
});
// ── Desktop still shows everything inline ──────────────────────
describe("desktop regression (contrasted with tablet)", () => {

View File

@@ -925,6 +925,57 @@ describe("Header", () => {
fireEvent.click(closeBtn);
expect(onSearchChange).toHaveBeenCalledWith("");
});
it("does not render mobile project switch trigger on desktop", () => {
const projects = [
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
];
renderHeader({
projects,
currentProject: projects[0],
onSelectProject: vi.fn(),
}, "desktop");
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
});
it("does not render mobile project switch trigger on tablet", () => {
const projects = [
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
];
renderHeader({
projects,
currentProject: projects[0],
onSelectProject: vi.fn(),
}, "tablet");
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
});
it("renders mobile project switch trigger on mobile with 2+ projects", () => {
const projects = [
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
];
renderHeader({
projects,
currentProject: projects[0],
onSelectProject: vi.fn(),
}, "mobile");
expect(screen.getByTestId("mobile-project-switch-trigger")).toBeDefined();
});
it("does not render mobile project switch trigger on mobile with single project", () => {
const projects = [
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
];
renderHeader({
projects,
currentProject: projects[0],
onSelectProject: vi.fn(),
}, "mobile");
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
});
});
describe("Back to All Projects button", () => {

View File

@@ -254,6 +254,7 @@ export function Header({
const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false);
const [isTerminalSubmenuOpen, setIsTerminalSubmenuOpen] = useState(false);
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isMobileProjectSwitchOpen, setIsMobileProjectSwitchOpen] = useState(false);
const [overflowScripts, setOverflowScripts] = useState<Record<string, string>>({});
const [overflowScriptsLoading, setOverflowScriptsLoading] = useState(false);
const overflowButtonRef = useRef<HTMLButtonElement>(null);
@@ -262,6 +263,7 @@ export function Header({
const mobileSearchInputRef = useRef<HTMLInputElement>(null);
const terminalSubmenuOpenRef = useRef(false);
const nodeSelectorRef = useRef<HTMLDivElement>(null);
const mobileProjectSwitchRef = useRef<HTMLDivElement>(null);
// Get remote nodes only (exclude local node type)
const remoteNodes = useMemo(() =>
@@ -372,6 +374,7 @@ export function Header({
setIsOverflowMenuOpen(false);
setIsMobileSearchOpen(false);
setIsNodeSelectorOpen(false);
setIsMobileProjectSwitchOpen(false);
}
};
@@ -379,6 +382,23 @@ export function Header({
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
// Close mobile project switch on outside click
useEffect(() => {
if (!isMobileProjectSwitchOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (
mobileProjectSwitchRef.current &&
!mobileProjectSwitchRef.current.contains(e.target as Node)
) {
setIsMobileProjectSwitchOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isMobileProjectSwitchOpen]);
const handleMobileSearchToggle = useCallback(() => {
setIsMobileSearchOpen((prev) => !prev);
}, []);
@@ -438,6 +458,61 @@ export function Header({
<h1 className="logo">Fusion</h1>
</div>
{/* Mobile Project Switch - dropdown trigger next to logo when 2+ projects (mobile only) */}
{isMobile && projects.length > 1 && onSelectProject && (
<div className="mobile-project-switch" ref={mobileProjectSwitchRef}>
<button
className={`mobile-project-switch-trigger${isMobileProjectSwitchOpen ? " mobile-project-switch-trigger--open" : ""}`}
onClick={() => setIsMobileProjectSwitchOpen((prev) => !prev)}
title="Switch project"
aria-label="Switch project"
aria-expanded={isMobileProjectSwitchOpen}
aria-haspopup="listbox"
data-testid="mobile-project-switch-trigger"
>
<ChevronDown size={14} className={`mobile-project-switch-chevron${isMobileProjectSwitchOpen ? " mobile-project-switch-chevron--open" : ""}`} />
</button>
{isMobileProjectSwitchOpen && (
<div
className="mobile-project-switch-dropdown"
role="listbox"
aria-label="Select project"
data-testid="mobile-project-switch-dropdown"
>
{projects.map((project) => {
const isCurrent = currentProject?.id === project.id;
const statusColor = PROJECT_STATUS_CONFIG[project.status]?.color;
return (
<button
key={project.id}
className={`mobile-project-switch-item${isCurrent ? " mobile-project-switch-item--current" : ""}`}
onClick={() => {
onSelectProject(project);
setIsMobileProjectSwitchOpen(false);
}}
role="option"
aria-selected={isCurrent}
data-testid={`mobile-project-switch-item-${project.id}`}
>
<span
className="mobile-project-switch-dot"
style={{ backgroundColor: statusColor || "var(--text-muted)" }}
/>
<div className="mobile-project-switch-info">
<span className="mobile-project-switch-name">{project.name}</span>
<span className="mobile-project-switch-path">
{project.path.split("/").slice(-2).join("/")}
</span>
</div>
{isCurrent && <Check size={14} className="mobile-project-switch-check" />}
</button>
);
})}
</div>
)}
</div>
)}
{/* Project Selector - Back button when project selected, dropdown when 2+ projects (desktop only) */}
{!isCompact && projects.length >= 1 && onViewAllProjects && (
<ProjectSelector

View File

@@ -998,6 +998,176 @@ describe("Header", () => {
fireEvent.click(closeBtn);
expect(onSearchChange).toHaveBeenCalledWith("");
});
// ── Mobile Project Switch (logo-adjacent) ─────────────────────
it("renders mobile project switch trigger when 2+ projects and onSelectProject provided", () => {
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: "" },
];
render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={vi.fn()}
/>
);
expect(screen.getByTestId("mobile-project-switch-trigger")).toBeDefined();
});
it("does not render mobile project switch trigger with single 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={projects[0]}
onSelectProject={vi.fn()}
/>
);
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
});
it("does not render mobile project switch trigger when onSelectProject is not provided", () => {
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: "" },
];
render(
<Header
projects={projects}
currentProject={projects[0]}
/>
);
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
});
it("mobile project switch trigger opens dropdown 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: "" },
];
render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={vi.fn()}
/>
);
const trigger = screen.getByTestId("mobile-project-switch-trigger");
fireEvent.click(trigger);
expect(screen.getByTestId("mobile-project-switch-dropdown")).toBeDefined();
});
it("mobile project switch dropdown shows all projects", () => {
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: "" },
];
render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
expect(screen.getByTestId("mobile-project-switch-item-proj_1")).toBeDefined();
expect(screen.getByTestId("mobile-project-switch-item-proj_2")).toBeDefined();
});
it("mobile project switch calls onSelectProject when project is selected", () => {
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: "" },
];
const onSelectProject = vi.fn();
render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={onSelectProject}
/>
);
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
fireEvent.click(screen.getByTestId("mobile-project-switch-item-proj_2"));
expect(onSelectProject).toHaveBeenCalledWith(projects[1]);
});
it("mobile project switch dropdown closes after selection", () => {
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: "" },
];
render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
expect(screen.getByTestId("mobile-project-switch-dropdown")).toBeDefined();
fireEvent.click(screen.getByTestId("mobile-project-switch-item-proj_2"));
expect(screen.queryByTestId("mobile-project-switch-dropdown")).toBeNull();
});
it("mobile project switch closes on outside click", () => {
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: "" },
];
render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
expect(screen.getByTestId("mobile-project-switch-dropdown")).toBeDefined();
fireEvent.mouseDown(document.body);
expect(screen.queryByTestId("mobile-project-switch-dropdown")).toBeNull();
});
it("mobile project switch closes on Escape key", () => {
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: "" },
];
render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
expect(screen.getByTestId("mobile-project-switch-dropdown")).toBeDefined();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByTestId("mobile-project-switch-dropdown")).toBeNull();
});
it("mobile project switch shows current project as selected", () => {
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: "" },
];
render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
const currentItem = screen.getByTestId("mobile-project-switch-item-proj_1");
expect(currentItem.getAttribute("aria-selected")).toBe("true");
const otherItem = screen.getByTestId("mobile-project-switch-item-proj_2");
expect(otherItem.getAttribute("aria-selected")).toBe("false");
});
});
// ── Project Selector ────────────────────────────────────

View File

@@ -19231,6 +19231,126 @@ html .column.drag-over * {
background: rgba(88, 166, 255, 0.1);
}
/* === Mobile Project Switch (logo-adjacent dropdown) === */
.mobile-project-switch {
position: relative;
display: none; /* Hidden by default, shown on mobile via media query */
}
.mobile-project-switch-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
min-height: 36px;
padding: 6px;
background: transparent;
border: none;
border-radius: var(--radius-md);
color: var(--text-muted);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.mobile-project-switch-trigger:hover {
background: var(--card-hover);
color: var(--text);
}
.mobile-project-switch-trigger--open {
color: var(--todo);
background: var(--card-hover);
}
.mobile-project-switch-chevron {
transition: transform var(--transition-fast);
}
.mobile-project-switch-chevron--open {
transform: rotate(180deg);
}
.mobile-project-switch-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 100;
min-width: 200px;
max-width: 280px;
padding: var(--space-xs);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface);
box-shadow: var(--shadow-lg);
}
.mobile-project-switch-item {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-sm);
padding: 10px 12px;
background: transparent;
border: none;
border-radius: var(--radius-md);
color: var(--text);
font-size: 13px;
cursor: pointer;
text-align: left;
transition: background var(--transition-fast);
}
.mobile-project-switch-item:hover {
background: var(--card-hover);
}
.mobile-project-switch-item--current {
background: var(--card-hover);
}
.mobile-project-switch-dot {
flex-shrink: 0;
width: 8px;
height: 8px;
border-radius: 50%;
}
.mobile-project-switch-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.mobile-project-switch-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.mobile-project-switch-path {
font-size: 11px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-project-switch-check {
flex-shrink: 0;
color: var(--todo);
}
/* Mobile styles for mobile-project-switch */
@media (max-width: 768px) {
.mobile-project-switch {
display: inline-flex;
align-items: center;
}
}
/* === Project Content Wrapper (footer-safe layout) === */
/**