FN-6819: keep sidebar settings above footer

Keep the experimental left sidebar Settings control clear of the fixed executor footer while preserving readable project selector fallbacks.

- Share the executor footer visibility state with the left sidebar and project content.
- Reserve executor-footer height on the sidebar when the status footer is visible so Settings stays clickable in expanded and collapsed modes.
- Add project-selector translation fallbacks for incomplete locale fixtures.
- Cover footer-clearance behavior in left sidebar tests and add a patch changeset.

Files changed:
 .changeset/fn-6819-sidebar-footer-clearance.md     |  5 +++
 packages/dashboard/app/App.tsx                     |  8 +++--
 .../dashboard/app/components/LeftSidebarNav.css    |  9 ++++++
 .../dashboard/app/components/LeftSidebarNav.tsx    |  4 ++-
 .../dashboard/app/components/ProjectSelector.css   |  2 +-
 .../dashboard/app/components/ProjectSelector.tsx   | 11 +++++--
 .../components/__tests__/LeftSidebarNav.test.tsx   | 36 ++++++++++++++++++++++
 7 files changed, 67 insertions(+), 8 deletions(-)

Fusion-Task-Id: FN-6819

Fusion-Task-Lineage: 950da852-8dcf-4379-a91b-0188b12d1a6c
This commit is contained in:
gsxdsm
2026-06-20 21:23:17 -07:00
parent 3c6db08270
commit 185ff70d86
7 changed files with 67 additions and 8 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix the experimental left sidebar Settings button so it remains clear of the fixed executor status footer, and keep project-selector fallback labels readable when translations are incomplete.

View File

@@ -1036,7 +1036,8 @@ function AppInner() {
Experimental left sidebar navigation replaces the Header view shortcuts with a persistent sidebar on non-mobile project screens, while mobile continues to use the bottom navigation bar as the only primary navigation surface. Experimental left sidebar navigation replaces the Header view shortcuts with a persistent sidebar on non-mobile project screens, while mobile continues to use the bottom navigation bar as the only primary navigation surface.
*/ */
const leftSidebarNavEnabled = experimentalFeatures.leftSidebarNav === true; const leftSidebarNavEnabled = experimentalFeatures.leftSidebarNav === true;
const sidebarActive = leftSidebarNavEnabled && !isMobile && viewMode === "project" && !!currentProject; const executorFooterVisible = viewMode === "project" && !!currentProject;
const sidebarActive = leftSidebarNavEnabled && !isMobile && executorFooterVisible;
const agentOnboardingEnabled = experimentalFeatures.agentOnboarding === true; const agentOnboardingEnabled = experimentalFeatures.agentOnboarding === true;
const agentsEnabled = true; const agentsEnabled = true;
@@ -2136,15 +2137,16 @@ function AppInner() {
currentProject={currentProject} currentProject={currentProject}
onSelectProject={handleSelectProject} onSelectProject={handleSelectProject}
onViewAllProjects={handleViewAllProjects} onViewAllProjects={handleViewAllProjects}
footerVisible={executorFooterVisible}
/> />
)} )}
<div <div
className={`project-content${viewMode === "project" && currentProject && (!isMobile || !mobileKeyboardOpen) ? " project-content--with-footer" : ""}${isMobile && !mobileKeyboardOpen ? " project-content--with-mobile-nav" : ""}`} className={`project-content${executorFooterVisible && (!isMobile || !mobileKeyboardOpen) ? " project-content--with-footer" : ""}${isMobile && !mobileKeyboardOpen ? " project-content--with-mobile-nav" : ""}`}
> >
{renderMainContent()} {renderMainContent()}
</div> </div>
</div> </div>
{viewMode === "project" && currentProject && ( {executorFooterVisible && currentProject && (
<ExecutorStatusBar <ExecutorStatusBar
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks} tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
projectId={currentProject.id} projectId={currentProject.id}

View File

@@ -8,6 +8,7 @@ The experimental sidebar is a persistent desktop/tablet navigation replacement f
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-sizing: border-box;
width: var(--left-sidebar-nav-width); width: var(--left-sidebar-nav-width);
min-width: var(--left-sidebar-nav-width); min-width: var(--left-sidebar-nav-width);
min-height: 0; min-height: 0;
@@ -16,6 +17,14 @@ The experimental sidebar is a persistent desktop/tablet navigation replacement f
color: var(--text); color: var(--text);
} }
/*
FNXC:Navigation 2026-06-20-00:00:
The left sidebar is a sibling of project-content, so it does not inherit project-content footer padding. When the fixed executor status bar is rendered, reserve the shared --executor-footer-height on the aside so the bottom Settings button remains visible and clickable above the footer.
*/
.left-sidebar-nav--with-footer {
padding-bottom: var(--executor-footer-height);
}
.left-sidebar-nav__collapse-toggle { .left-sidebar-nav__collapse-toggle {
flex-shrink: 0; flex-shrink: 0;
} }

View File

@@ -115,6 +115,7 @@ export interface LeftSidebarNavProps {
currentProject?: ProjectInfo | null; currentProject?: ProjectInfo | null;
onSelectProject?: (project: ProjectInfo) => void; onSelectProject?: (project: ProjectInfo) => void;
onViewAllProjects?: () => void; onViewAllProjects?: () => void;
footerVisible?: boolean;
} }
function formatCount(count: number): string { function formatCount(count: number): string {
@@ -160,6 +161,7 @@ export function LeftSidebarNav({
pluginDashboardViews = [], pluginDashboardViews = [],
showAgentsTab = false, showAgentsTab = false,
showSkillsTab = false, showSkillsTab = false,
footerVisible = false,
}: LeftSidebarNavProps) { }: LeftSidebarNavProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const [sidebarWidth, setSidebarWidth] = useState(readStoredSidebarWidth); const [sidebarWidth, setSidebarWidth] = useState(readStoredSidebarWidth);
@@ -390,7 +392,7 @@ export function LeftSidebarNav({
return ( return (
<aside <aside
className={`left-sidebar-nav${isCollapsed ? " left-sidebar-nav--collapsed" : ""}`} className={`left-sidebar-nav${isCollapsed ? " left-sidebar-nav--collapsed" : ""}${footerVisible ? " left-sidebar-nav--with-footer" : ""}`}
data-testid="left-sidebar-nav" data-testid="left-sidebar-nav"
aria-label={t("nav.sidebarAriaLabel", "Sidebar navigation")} aria-label={t("nav.sidebarAriaLabel", "Sidebar navigation")}
style={isCollapsed ? undefined : { width: sidebarWidth, minWidth: sidebarWidth }} style={isCollapsed ? undefined : { width: sidebarWidth, minWidth: sidebarWidth }}

View File

@@ -580,6 +580,7 @@
* On mobile the token is overridden to 32px to match the shorter footer. * On mobile the token is overridden to 32px to match the shorter footer.
*/ */
.dashboard-project-shell { .dashboard-project-shell {
--executor-footer-height: 36px;
display: flex; display: flex;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -602,7 +603,6 @@
} }
.project-content--with-footer { .project-content--with-footer {
--executor-footer-height: 36px;
padding-bottom: var(--executor-footer-height); padding-bottom: var(--executor-footer-height);
} }

View File

@@ -83,6 +83,11 @@ export function ProjectSelector({
viewAllLabel, viewAllLabel,
}: ProjectSelectorProps) { }: ProjectSelectorProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
/*
* FNXC:ProjectSelector 2026-06-20-20:51:
* The trigger fallback must remain readable when translation fixtures omit the optional project-selector key; provide the English default at the component seam instead of leaking the i18n key into the header.
*/
const projectsLabel = t("projectSelector.projects", "Projects");
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(-1); const [highlightedIndex, setHighlightedIndex] = useState(-1);
@@ -389,12 +394,12 @@ export function ProjectSelector({
aria-expanded={isOpen} aria-expanded={isOpen}
aria-haspopup="listbox" aria-haspopup="listbox"
aria-label={t("projectSelector.ariaLabel", "Select project")} aria-label={t("projectSelector.ariaLabel", "Select project")}
title={currentProject?.name ? t("projectSelector.switchProjectTitle", "Switch project (current: {{name}})", { name: currentProject.name }) : t("projectSelector.projectsTitle")} title={currentProject?.name ? t("projectSelector.switchProjectTitle", "Switch project (current: {{name}})", { name: currentProject.name }) : t("projectSelector.projectsTitle", "Projects")}
data-testid="project-selector-trigger" data-testid="project-selector-trigger"
> >
<Folder size={16} className="project-selector__trigger-icon" /> <Folder size={16} className="project-selector__trigger-icon" />
<span className="project-selector__trigger-text"> <span className="project-selector__trigger-text">
{currentProject?.name || t("projectSelector.projects")} {currentProject?.name || projectsLabel}
</span> </span>
<ChevronDown <ChevronDown
size={14} size={14}
@@ -407,7 +412,7 @@ export function ProjectSelector({
<div <div
className="project-selector__dropdown" className="project-selector__dropdown"
role="listbox" role="listbox"
aria-label={t("projectSelector.projects")} aria-label={projectsLabel}
onKeyDown={handleDropdownKeyDown} onKeyDown={handleDropdownKeyDown}
data-testid="project-selector-dropdown" data-testid="project-selector-dropdown"
> >

View File

@@ -57,6 +57,12 @@ function expectNoSidebarBrandOrProjectAffordances(container: HTMLElement) {
expect(container.querySelector(".left-sidebar-nav__wordmark")).toBeNull(); expect(container.querySelector(".left-sidebar-nav__wordmark")).toBeNull();
} }
function expectSettingsLastInFooter() {
const footer = screen.getByTestId("sidebar-nav-settings").closest(".left-sidebar-nav__footer");
expect(footer).not.toBeNull();
expect(footer?.lastElementChild).toBe(screen.getByTestId("sidebar-nav-settings"));
}
function renderSidebar(overrides: Partial<ComponentProps<typeof LeftSidebarNav>> = {}) { function renderSidebar(overrides: Partial<ComponentProps<typeof LeftSidebarNav>> = {}) {
const onChangeView = vi.fn(); const onChangeView = vi.fn();
const props: ComponentProps<typeof LeftSidebarNav> = { const props: ComponentProps<typeof LeftSidebarNav> = {
@@ -127,6 +133,36 @@ describe("LeftSidebarNav", () => {
expect(sidebarButtons.at(-1)).toBe(screen.getByTestId("sidebar-nav-settings")); expect(sidebarButtons.at(-1)).toBe(screen.getByTestId("sidebar-nav-settings"));
}); });
it.each([
["expanded", false],
["collapsed", true],
])("applies footer clearance only when the executor footer is visible in %s mode", (_label, collapsed) => {
if (collapsed) {
window.localStorage.setItem("fusion:left-sidebar-collapsed", "true");
}
const withFooter = renderSidebar({ footerVisible: true });
const sidebarWithFooter = screen.getByTestId("left-sidebar-nav");
expect(sidebarWithFooter).toHaveClass("left-sidebar-nav--with-footer");
if (collapsed) {
expect(sidebarWithFooter).toHaveClass("left-sidebar-nav--collapsed");
}
expectSettingsLastInFooter();
withFooter.unmount();
if (collapsed) {
window.localStorage.setItem("fusion:left-sidebar-collapsed", "true");
}
renderSidebar();
const sidebarWithoutFooter = screen.getByTestId("left-sidebar-nav");
expect(sidebarWithoutFooter).not.toHaveClass("left-sidebar-nav--with-footer");
if (collapsed) {
expect(sidebarWithoutFooter).toHaveClass("left-sidebar-nav--collapsed");
}
expectSettingsLastInFooter();
});
it("gates optional destinations on their matching feature flags and props while preserving bottom settings", () => { it("gates optional destinations on their matching feature flags and props while preserving bottom settings", () => {
renderSidebar({ renderSidebar({
showAgentsTab: false, showAgentsTab: false,