Files
fusion/packages/dashboard/app/components/MobileNavBar.tsx
gsxdsm 5b2b31d2c9 FN-8762: extract Todo Lists into bundled plugin
Move Todo Lists into a bundled, project-enabled plugin package.

- Move Todo UI, client API, and server routes into the plugin package.
- Register and bundle Todo as an enabled plugin dashboard view rather than a static host feature.
- Preserve Todo route validation and server-error semantics, including task assignment agent lookup.
- Keep disabled and legacy Todo views out of project navigation and main content.

Files changed:
 .changeset/fn-8762-todos-plugin.md                 |   7 +
 AGENTS.md                                          |   3 +-
 docs/PLUGIN_AUTHORING.md                           |   4 +
 docs/dashboard-guide.md                            |   4 +
 docs/todo-view.md                                  | 151 +----
 .../cli/src/plugins/staged-bundled-plugin-ids.ts   |   1 +
 packages/cli/tsup.config.ts                        |   8 +
 .../core/src/board/mobile-nav-primary-items.ts     |   2 -
 .../__tests__/bundled-plugin-install.test.ts       |   2 +
 .../core/src/plugins/bundled-plugin-install.ts     |   1 +
 packages/dashboard/app/App.tsx                     |  18 +-
 .../app/__tests__/lazy-loaded-views-docs.test.ts   |   9 +-
 packages/dashboard/app/api/legacy.ts               |  15 -
 packages/dashboard/app/api/system/index.ts         |   1 -
 packages/dashboard/app/api/system/todo.ts          |  85 ---
 packages/dashboard/app/components/Header.tsx       |  23 +-
 .../dashboard/app/components/LeftSidebarNav.tsx    |   1 -
 packages/dashboard/app/components/MobileNavBar.tsx |   4 -
 .../dashboard/app/components/SettingsModal.tsx     |   2 -
 .../app/components/__tests__/App.test.tsx          |   7 -
 .../app/components/__tests__/Header.test.tsx       |  42 --
 .../app/components/__tests__/RightDock.test.tsx    |  18 +-
 ...skDetail.mobile-transition.board-panel.test.tsx |   1 -
 .../__tests__/TaskDetail.swipe-back.test.tsx       |   1 -
 .../__tests__/TodoView.mobile-css.test.ts          |  66 ---
 .../app/components/__tests__/TodoView.test.tsx     | 649 ---------------------
 .../__tests__/navigation-history.test.tsx          |   3 -
 .../__tests__/overflowViewRegistry.test.tsx        | 118 +---
 .../app/components/dashboard/MainContent.tsx       |  27 +-
 .../dashboard/app/components/dashboard/types.ts    |   6 +-
 .../app/components/overflowViewRegistry.tsx        |  21 +-
 .../app/hooks/__tests__/useTodoLists.test.ts       | 291 ---------
 .../app/hooks/__tests__/useViewState.test.ts       |  11 +
 packages/dashboard/app/hooks/useAppSettings.ts     |   5 -
 packages/dashboard/app/hooks/useViewState.ts       |   5 +
 .../__tests__/registerBundledPluginViews.test.tsx  |  14 +
 packages/dashboard/app/plugins/bundled-todos.d.ts  |   5 +
 .../app/plugins/registerBundledPluginViews.ts      |  18 +
 packages/dashboard/app/plugins/types.ts            |   4 +
 .../src/__tests__/todo-documentation.test.ts       |  68 ---
 .../dashboard/src/__tests__/todo-routes.test.ts    | 577 ------------------
 packages/dashboard/src/registry-manifest.json      | 101 +++-
 packages/dashboard/src/routes.ts                   |   1 -
 .../src/routes/plugin-bundled-runtimes.ts          |   1 +
 .../src/routes/register-integrated-routers.ts      |   2 -
 packages/dashboard/src/shared/dashboard-views.ts   |   6 -
 packages/dashboard/src/todo-routes.ts              | 342 -----------
 packages/dashboard/vite.config.ts                  |   8 +
 packages/dashboard/vitest.config.ts                |   8 +
 packages/desktop/scripts/workspace-tools.ts        |   1 +
 plugins/fusion-plugin-todos/README.md              |  20 +
 plugins/fusion-plugin-todos/manifest.json          |   6 +
 plugins/fusion-plugin-todos/package.json           |  38 ++
 .../fusion-plugin-todos/src/dashboard-interop.d.ts |  12 +
 plugins/fusion-plugin-todos/src/dashboard-view.tsx |   4 +
 .../src/dashboard/LoadingSpinner.tsx               |   1 +
 .../src/dashboard}/TodoView.css                    |   0
 .../src/dashboard}/TodoView.tsx                    |  16 +-
 plugins/fusion-plugin-todos/src/dashboard/api.ts   |  15 +
 .../src/dashboard/projectStorage.ts                |   3 +
 .../fusion-plugin-todos/src/dashboard/swrCache.ts  |   6 +
 .../src/dashboard/useConfirm.ts                    |   1 +
 .../src/dashboard}/useTodoLists.ts                 |   4 +-
 plugins/fusion-plugin-todos/src/index.ts           |   4 +
 .../fusion-plugin-todos/src/todo-routes.test.ts    |  46 ++
 plugins/fusion-plugin-todos/src/todo-routes.ts     | 156 +++++
 plugins/fusion-plugin-todos/tsconfig.json          |  28 +
 plugins/fusion-plugin-todos/vitest.config.ts       |   9 +
 pnpm-lock.yaml                                     |  64 +-
 pnpm-workspace.yaml                                |   1 +
 70 files changed, 671 insertions(+), 2531 deletions(-)

Fusion-Task-Id: FN-8762
Fusion-Task-Lineage: 3beb502c-3793-451b-b357-50c243395410
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-03 09:04:45 -07:00

687 lines
33 KiB
TypeScript

import "./MobileNavBar.css";
import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
Activity,
Bot,
Brain,
ChevronRight,
Clock,
FileCode,
FileText,
Folder,
Gauge,
GitBranch,
Grid3X3,
LayoutGrid,
Lightbulb,
Loader2,
Lock,
Mail,
MessageSquare,
MoreHorizontal,
Play,
Settings,
Monitor,
Search,
Sparkles,
Target,
Terminal,
Workflow,
Zap,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { fetchScripts } from "../api";
import type { PluginDashboardViewEntry } from "../api";
import { useViewportMode } from "./Header";
import { NavigationHistoryContext } from "../hooks/useNavigationHistory";
import type { TaskView } from "../hooks/useViewState";
import { buildPluginTaskViewId, isPluginViewId } from "../plugins/pluginViewRegistry";
import { getPluginDashboardViewNavIcon } from "./pluginNavIcon";
import { resolveMobileNavPrimaryItems, type MobileNavSelectableItem } from "../../../core/src/board/mobile-nav-primary-items";
export interface PublishedMobileNavHeightInput {
navOffsetHeight: number;
paddingBottom: number;
tabHeights: number[];
}
export function computePublishedMobileNavHeight({
navOffsetHeight,
paddingBottom,
tabHeights,
}: PublishedMobileNavHeightInput): number {
const measuredTabHeight = Math.max(0, ...tabHeights.filter((height) => Number.isFinite(height)));
if (measuredTabHeight > 0) {
return Math.max(44, Math.ceil(measuredTabHeight));
}
const resolvedPaddingBottom = Number.isFinite(paddingBottom) ? paddingBottom : 0;
const contentHeight = navOffsetHeight - resolvedPaddingBottom;
return Math.max(44, Math.ceil(contentHeight));
}
export interface MobileNavBarProps {
/** Current task view mode */
view: TaskView;
/** Change task view handler */
onChangeView: (view: TaskView) => void;
/** Whether the ExecutorStatusBar footer is visible */
footerVisible: boolean;
/** Whether any full-screen modal is currently open (hides the tab bar) */
modalOpen?: boolean;
/*
FNXC:Navigation 2026-07-25-22:57:
The project overview has no task tabs to drive, so callers hide the mobile bar rather than rendering a non-functional navigation shell.
*/
hidden?: boolean;
/** Whether the on-screen mobile keyboard is open */
keyboardOpen?: boolean;
// Navigation handlers
onOpenSettings?: () => void;
onOpenActivityLog?: () => void;
onOpenMailbox?: () => void;
mailboxUnreadCount?: number;
mailboxPendingApprovalCount?: number;
chatHasUnreadResponse?: boolean;
stashOrphanCount?: number;
onOpenGitManager?: () => void;
onOpenWorkflowEditor?: () => void;
onOpenSchedules?: () => void;
onOpenScripts?: () => void;
onToggleTerminal?: () => void;
onOpenFiles?: () => void;
onOpenGitHubImport?: () => void;
onOpenPlanning?: () => void;
onResumePlanning?: () => void;
activePlanningSessionCount?: number;
/*
FNXC:Navigation 2026-07-05-00:00:
Planning Mode "awaiting input" no longer shows a top-of-board banner (its Resume button did not reliably
redirect). Instead this flag drives a yellow `status-dot--pending` dot on the Planning More-sheet item and the
More tab icon, mirroring `chatHasUnreadResponse`'s `mobile-nav-chat-unread-dot`, so the click target is always
the working Planning navigation.
*/
planningNeedsInput?: boolean;
onOpenUsage?: () => void;
onRunScript?: (name: string, command: string) => void;
projectId?: string;
onViewAllProjects?: () => void;
/** Whether to show the skills tab */
showSkillsTab?: boolean;
/** Experimental feature flags controlling visibility of nav items. */
experimentalFeatures?: {
insights?: boolean;
memoryView?: boolean;
devServer?: boolean;
devServerView?: boolean;
researchView?: boolean;
evalsView?: boolean;
ideationView?: boolean;
goalsView?: boolean;
};
pluginDashboardViews?: PluginDashboardViewEntry[];
shellConnectionControl?: ReactNode;
/** Ordered quick-action tabs; invalid values resolve to the safe default. */
mobileNavPrimaryItems?: string[];
}
function GitHubLogo({ size = 20 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg>
);
}
function formatCount(count: number): string {
return count > 99 ? "99+" : String(count);
}
export function MobileNavBar({
view,
onChangeView,
footerVisible,
modalOpen = false,
hidden = false,
keyboardOpen = false,
onOpenSettings,
onOpenActivityLog,
mailboxUnreadCount = 0,
mailboxPendingApprovalCount = 0,
chatHasUnreadResponse = false,
stashOrphanCount = 0,
onOpenGitManager,
onOpenWorkflowEditor,
onOpenSchedules,
onOpenScripts,
onToggleTerminal,
onOpenFiles,
onOpenGitHubImport,
onOpenPlanning,
onResumePlanning,
activePlanningSessionCount = 0,
planningNeedsInput = false,
onOpenUsage,
onRunScript,
projectId,
onViewAllProjects,
showSkillsTab,
experimentalFeatures,
pluginDashboardViews = [],
shellConnectionControl,
mobileNavPrimaryItems,
}: MobileNavBarProps) {
const { t } = useTranslation("app");
const mode = useViewportMode();
const navigationHistory = useContext(NavigationHistoryContext);
const [isMoreOpen, setIsMoreOpen] = useState(false);
const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false);
const [scripts, setScripts] = useState<Record<string, string>>({});
const [scriptsLoading, setScriptsLoading] = useState(false);
const [dragOffset, setDragOffset] = useState(0);
const [isSheetDragging, setIsSheetDragging] = useState(false);
const [hasSheetDragged, setHasSheetDragged] = useState(false);
const navRef = useRef<HTMLElement | null>(null);
const sheetRef = useRef<HTMLDivElement | null>(null);
const sheetDragRef = useRef<{
startY: number;
startedAt: number;
eligible: boolean;
startedOnHandle: boolean;
} | null>(null);
const dragOffsetRef = useRef(0);
const scriptEntries = useMemo(
() => Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b)),
[scripts],
);
// Fetch scripts when the submenu opens
useEffect(() => {
if (!isScriptsSubmenuOpen) return;
let cancelled = false;
setScriptsLoading(true);
fetchScripts(projectId)
.then((data) => {
if (!cancelled) setScripts(data);
})
.catch(() => {
if (!cancelled) setScripts({});
})
.finally(() => {
if (!cancelled) setScriptsLoading(false);
});
return () => {
cancelled = true;
};
}, [isScriptsSubmenuOpen, projectId]);
const resetSheetDrag = useCallback(() => {
sheetDragRef.current = null;
dragOffsetRef.current = 0;
setDragOffset(0);
setIsSheetDragging(false);
}, []);
const closeMore = useCallback(() => {
resetSheetDrag();
setHasSheetDragged(false);
setIsMoreOpen(false);
}, [resetSheetDrag]);
/*
FNXC:MobileNav 2026-07-16-14:30:
The More sheet must dismiss before navigation on iOS swipe-back, Android native Back, and browser Back.
Register its stable, idempotent closer as a modal entry, while reading nullable context so provider-less
component renders retain their existing behavior.
*/
useEffect(() => {
if (!isMoreOpen || !navigationHistory) return;
navigationHistory.pushNav({ type: "modal", close: closeMore });
}, [closeMore, isMoreOpen, navigationHistory]);
const dismissMore = useCallback((forNavigation?: boolean) => {
navigationHistory?.removeNav(
closeMore,
forNavigation === true ? { preserveHistoryPosition: true } : undefined,
);
closeMore();
}, [closeMore, navigationHistory]);
/*
FNXC:MobileNav 2026-07-16-12:00:
The mobile More drawer must dismiss when a user drags down from its top or grab handle.
Keep scrollTop===0 as the body-drag guard so a scrolled sheet retains normal interior
scrolling; the non-passive native listener can cancel iOS Safari and Android Chrome
overscroll only for that eligible downward dismissal gesture.
*/
useEffect(() => {
const sheet = sheetRef.current;
if (!isMoreOpen || !sheet) return;
const onTouchMove = (event: TouchEvent) => {
const drag = sheetDragRef.current;
const touch = event.touches[0];
if (!drag || !touch) return;
const offset = Math.max(0, touch.clientY - drag.startY);
const canDismiss = drag.eligible && (drag.startedOnHandle || sheet.scrollTop <= 0);
if (offset === 0 || !canDismiss) return;
event.preventDefault();
dragOffsetRef.current = offset;
setHasSheetDragged(true);
setIsSheetDragging(true);
setDragOffset(offset);
};
sheet.addEventListener("touchmove", onTouchMove, { passive: false });
return () => sheet.removeEventListener("touchmove", onTouchMove);
}, [isMoreOpen]);
const handleSheetTouchStart = useCallback((event: React.TouchEvent<HTMLDivElement>) => {
const sheet = sheetRef.current;
const touch = event.touches[0];
if (!sheet || !touch) return;
const target = event.target instanceof Element ? event.target : null;
const startedOnHandle = Boolean(target?.closest(".mobile-more-sheet-handle"));
sheetDragRef.current = {
startY: touch.clientY,
startedAt: Date.now(),
eligible: startedOnHandle || sheet.scrollTop <= 0,
startedOnHandle,
};
dragOffsetRef.current = 0;
setDragOffset(0);
setIsSheetDragging(false);
}, []);
const finishSheetDrag = useCallback((event: React.TouchEvent<HTMLDivElement>) => {
const sheet = sheetRef.current;
const drag = sheetDragRef.current;
const touch = event.changedTouches[0];
const offset = dragOffsetRef.current;
const elapsed = drag ? Math.max(1, Date.now() - drag.startedAt) : 1;
const sheetHeight = sheet?.getBoundingClientRect().height ?? 0;
const dismissDistance = Math.max(100, sheetHeight / 4);
const shouldDismiss = Boolean(drag?.eligible && offset > 0 && (offset >= dismissDistance || offset / elapsed >= 0.6));
resetSheetDrag();
if (shouldDismiss && touch) {
dismissMore();
}
}, [dismissMore, resetSheetDrag]);
/*
FNXC:GitHubImportSwipeBack 2026-07-20-23:12:
More actions transition directly into their destination. Preserve the
current history position while removing More so its asynchronous back
consumption cannot dismiss Import or its nested candidate detail afterward.
*/
const handleMoreAction = useCallback(
(callback?: () => void) => {
dismissMore(true);
callback?.();
},
[dismissMore],
);
useEffect(() => {
if (!isMoreOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
dismissMore();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [dismissMore, isMoreOpen]);
useLayoutEffect(() => {
/*
FNXC:Navigation 2026-07-25-22:57:
A hidden overview bar must remove its published height as well as its DOM shell; otherwise project content retains dead bottom space.
*/
if (hidden) {
document.documentElement.style.removeProperty("--mobile-nav-height");
return;
}
const navEl = navRef.current;
if (!navEl || typeof document === "undefined") {
return;
}
const publishMeasuredHeight = () => {
const computed = window.getComputedStyle(navEl);
const paddingBottom = Number.parseFloat(computed.paddingBottom);
const tabHeights = Array.from(navEl.querySelectorAll<HTMLElement>(".mobile-nav-tab"), (tab) => tab.getBoundingClientRect().height);
const publishedHeight = computePublishedMobileNavHeight({
navOffsetHeight: navEl.offsetHeight,
paddingBottom,
tabHeights,
});
document.documentElement.style.setProperty("--mobile-nav-height", `${publishedHeight}px`);
};
publishMeasuredHeight();
let observer: ResizeObserver | null = null;
if (typeof ResizeObserver !== "undefined") {
observer = new ResizeObserver(() => {
publishMeasuredHeight();
});
observer.observe(navEl);
}
return () => {
observer?.disconnect();
document.documentElement.style.removeProperty("--mobile-nav-height");
};
}, [hidden]);
if (mode !== "mobile" || modalOpen || hidden) {
return null;
}
const planningHandler = activePlanningSessionCount > 0 && onResumePlanning ? onResumePlanning : onOpenPlanning;
const skillsEnabled = Boolean(showSkillsTab);
const sortedPrimaryPluginViews = pluginDashboardViews
.filter((entry) => entry.view.placement === "primary")
.sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER));
/*
FNXC:Navigation 2026-06-19-12:05:
Mobile navigation adds Command Center as a fixed top-level tab immediately after Mailbox.
Primary plugin tabs, including Compound Engineering, are demoted to the More sheet so touch targets stay wide and Command Center is not duplicated.
FNXC:Navigation 2026-06-19-08:24:
FN-6725 re-verified the suspected-revert surface: Command Center remains adjacent to Mailbox even when mailbox badges render, primary plugin tabs remain More-sheet-only, and no Command Center More-sheet duplicate is allowed.
*/
const MAX_PRIMARY_PLUGIN_TOP_LEVEL_TABS = 0;
const topLevelPrimaryPluginViews = sortedPrimaryPluginViews.slice(0, MAX_PRIMARY_PLUGIN_TOP_LEVEL_TABS);
const topLevelPluginViewKeys = new Set(
topLevelPrimaryPluginViews.map((entry) => `${entry.pluginId}:${entry.view.viewId}`),
);
const overflowPluginViews = pluginDashboardViews
.filter((entry) => !topLevelPluginViewKeys.has(`${entry.pluginId}:${entry.view.viewId}`))
.sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER));
const { primaryItems, omittedItems } = resolveMobileNavPrimaryItems({ mobileNavPrimaryItems });
/*
FNXC:Navigation 2026-07-17-00:00:
Selectable mobile destinations use one registry so an available destination is rendered exactly once:
configured items are tabs and omitted items are More entries. Feature gates are enforced here rather
than by the core resolver, keeping persisted choices valid if an operator later enables a feature.
*/
const destinationRegistry: Record<MobileNavSelectableItem, {
icon: ReactNode;
labelKey: string;
fallback: string;
moreTestId: string;
isActive: boolean;
isAvailable: boolean;
navigate: (surface: "primary" | "more") => void;
indicator?: boolean;
indicatorLabel?: string;
badge?: number;
}> = {
"command-center": { icon: <Gauge />, labelKey: "nav.commandCenter", fallback: "Dashboard", moreTestId: "mobile-more-item-command-center", isActive: view === "command-center", isAvailable: true, navigate: () => onChangeView("command-center") },
tasks: { icon: <LayoutGrid />, labelKey: "nav.tasks", fallback: "Tasks", moreTestId: "mobile-more-item-tasks", isActive: view === "board" || view === "list", isAvailable: true, navigate: () => onChangeView(view === "board" || view === "list" ? view : "board") },
agents: { icon: <Bot />, labelKey: "nav.agents", fallback: "Agents", moreTestId: "mobile-more-item-agents", isActive: view === "agents", isAvailable: true, navigate: () => onChangeView("agents") },
missions: { icon: <Target />, labelKey: "nav.missions", fallback: "Missions", moreTestId: "mobile-more-item-missions", isActive: view === "missions", isAvailable: true, navigate: () => onChangeView("missions") },
chat: { icon: <MessageSquare />, labelKey: "nav.chat", fallback: "Chat", moreTestId: "mobile-more-item-chat", isActive: view === "chat", isAvailable: true, navigate: () => onChangeView("chat"), indicator: chatHasUnreadResponse && view !== "chat", indicatorLabel: t("nav.chatUnreadAriaLabel", "Unread chat response") },
mailbox: { icon: <Mail />, labelKey: "nav.mailbox", fallback: "Mailbox", moreTestId: "mobile-more-item-mailbox", isActive: view === "mailbox", isAvailable: true, navigate: () => onChangeView("mailbox"), indicator: mailboxPendingApprovalCount > 0 && view !== "mailbox", indicatorLabel: t("nav.mailboxPendingAriaLabel", "Pending approvals"), badge: mailboxUnreadCount },
planning: { icon: <Lightbulb />, labelKey: "nav.planning", fallback: "Planning", moreTestId: "mobile-more-item-planning", isActive: view === "planning", isAvailable: true, navigate: (surface) => surface === "primary" ? planningHandler?.() : handleMoreAction(planningHandler), indicator: planningNeedsInput && view !== "planning", indicatorLabel: t("nav.planningNeedsInputAriaLabel", "Planning needs your input"), badge: activePlanningSessionCount },
activity: { icon: <Activity />, labelKey: "nav.activityLog", fallback: "Activity Log", moreTestId: "mobile-more-item-activity", isActive: false, isAvailable: true, navigate: (surface) => surface === "primary" ? onOpenActivityLog?.() : handleMoreAction(onOpenActivityLog) },
git: { icon: <GitBranch />, labelKey: "nav.gitManager", fallback: "Git Manager", moreTestId: "mobile-more-item-git", isActive: false, isAvailable: true, navigate: (surface) => surface === "primary" ? onOpenGitManager?.() : handleMoreAction(onOpenGitManager), badge: stashOrphanCount },
files: { icon: <Folder />, labelKey: "nav.files", fallback: "Files", moreTestId: "mobile-more-item-files", isActive: false, isAvailable: true, navigate: (surface) => surface === "primary" ? onOpenFiles?.() : handleMoreAction(onOpenFiles) },
workflows: { icon: <Workflow />, labelKey: "nav.workflows", fallback: "Workflows", moreTestId: "mobile-more-item-workflow", isActive: false, isAvailable: true, navigate: (surface) => surface === "primary" ? onOpenWorkflowEditor?.() : handleMoreAction(onOpenWorkflowEditor) },
automation: { icon: <Clock />, labelKey: "nav.automation", fallback: "Automation", moreTestId: "mobile-more-item-schedules", isActive: false, isAvailable: true, navigate: (surface) => surface === "primary" ? onOpenSchedules?.() : handleMoreAction(onOpenSchedules) },
"github-import": { icon: <GitHubLogo />, labelKey: "nav.importFromGitHub", fallback: "Import from GitHub", moreTestId: "mobile-more-item-github", isActive: false, isAvailable: true, navigate: (surface) => surface === "primary" ? onOpenGitHubImport?.() : handleMoreAction(onOpenGitHubImport) },
usage: { icon: <Activity />, labelKey: "nav.usage", fallback: "Usage", moreTestId: "mobile-more-item-usage", isActive: false, isAvailable: true, navigate: (surface) => surface === "primary" ? onOpenUsage?.() : handleMoreAction(onOpenUsage) },
projects: { icon: <Grid3X3 />, labelKey: "nav.projects", fallback: "Projects", moreTestId: "mobile-more-item-projects", isActive: false, isAvailable: true, navigate: (surface) => surface === "primary" ? onViewAllProjects?.() : handleMoreAction(onViewAllProjects) },
documents: { icon: <FileText />, labelKey: "nav.documents", fallback: "Artifacts", moreTestId: "mobile-more-item-documents", isActive: view === "documents", isAvailable: true, navigate: (surface) => surface === "primary" ? onChangeView("documents") : handleMoreAction(() => onChangeView("documents")) },
secrets: { icon: <Lock />, labelKey: "nav.secrets", fallback: "Secrets", moreTestId: "mobile-more-item-secrets", isActive: view === "secrets", isAvailable: true, navigate: (surface) => surface === "primary" ? onChangeView("secrets") : handleMoreAction(() => onChangeView("secrets")) },
settings: { icon: <Settings />, labelKey: "nav.settings", fallback: "Settings", moreTestId: "mobile-more-item-settings", isActive: false, isAvailable: true, navigate: (surface) => surface === "primary" ? onOpenSettings?.() : handleMoreAction(onOpenSettings) },
skills: { icon: <Zap />, labelKey: "nav.skills", fallback: "Skills", moreTestId: "mobile-more-item-skills", isActive: view === "skills", isAvailable: skillsEnabled, navigate: (surface) => surface === "primary" ? onChangeView("skills") : handleMoreAction(() => onChangeView("skills")) },
insights: { icon: <Sparkles />, labelKey: "nav.insights", fallback: "Insights", moreTestId: "mobile-more-item-insights", isActive: view === "insights", isAvailable: Boolean(experimentalFeatures?.insights), navigate: (surface) => surface === "primary" ? onChangeView("insights") : handleMoreAction(() => onChangeView("insights")) },
memory: { icon: <Brain />, labelKey: "nav.memory", fallback: "Memory", moreTestId: "mobile-more-item-memory", isActive: view === "memory", isAvailable: Boolean(experimentalFeatures?.memoryView), navigate: (surface) => surface === "primary" ? onChangeView("memory") : handleMoreAction(() => onChangeView("memory")) },
research: { icon: <Search />, labelKey: "nav.research", fallback: "Research", moreTestId: "mobile-more-item-research", isActive: view === "research", isAvailable: Boolean(experimentalFeatures?.researchView), navigate: (surface) => surface === "primary" ? onChangeView("research") : handleMoreAction(() => onChangeView("research")) },
evals: { icon: <Target />, labelKey: "nav.evals", fallback: "Evals", moreTestId: "mobile-more-item-evals", isActive: view === "evals", isAvailable: Boolean(experimentalFeatures?.evalsView), navigate: (surface) => surface === "primary" ? onChangeView("evals") : handleMoreAction(() => onChangeView("evals")) },
ideation: { icon: <Lightbulb />, labelKey: "nav.ideation", fallback: "Ideation", moreTestId: "mobile-more-item-ideation", isActive: view === "ideation", isAvailable: Boolean(experimentalFeatures?.ideationView), navigate: (surface) => surface === "primary" ? onChangeView("ideation") : handleMoreAction(() => onChangeView("ideation")) },
goals: { icon: <Target />, labelKey: "nav.goals", fallback: "Goals", moreTestId: "mobile-more-item-goals", isActive: view === "goalsView", isAvailable: Boolean(experimentalFeatures?.goalsView), navigate: (surface) => surface === "primary" ? onChangeView("goalsView") : handleMoreAction(() => onChangeView("goalsView")) },
"dev-server": { icon: <Monitor />, labelKey: "nav.devServer", fallback: "Dev Server", moreTestId: "mobile-more-item-dev-server", isActive: view === "dev-server" || view === "devserver", isAvailable: Boolean(experimentalFeatures?.devServerView), navigate: (surface) => surface === "primary" ? onChangeView("dev-server") : handleMoreAction(() => onChangeView("dev-server")) },
};
const effectivePrimaryItems = primaryItems.filter((item) => destinationRegistry[item].isAvailable);
const effectiveOmittedItems = omittedItems.filter((item) => destinationRegistry[item].isAvailable);
const isMoreActive = effectiveOmittedItems.some((item) => destinationRegistry[item].isActive)
|| view === "graph"
|| (isPluginViewId(view) && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
const renderSelectableItem = (item: MobileNavSelectableItem, surface: "primary" | "more") => {
const destination = destinationRegistry[item];
const isPrimary = surface === "primary";
const label = t(destination.labelKey, destination.fallback);
if (isPrimary) return <button key={item} type="button" className={`mobile-nav-tab${destination.isActive ? " mobile-nav-tab--active" : ""}`} data-testid={`mobile-nav-tab-${item}`} role="tab" aria-selected={destination.isActive} onClick={() => destination.navigate("primary")}><span className="mobile-nav-tab-icon-wrapper">{destination.icon}{destination.indicator && <span className="status-dot status-dot--pending mobile-nav-chat-unread-dot" aria-label={destination.indicatorLabel} />}</span><span className="mobile-nav-tab-label">{label}</span>{destination.badge && destination.badge > 0 ? <span className="mobile-nav-tab-badge">{formatCount(destination.badge)}</span> : null}</button>;
return <button key={item} type="button" className="mobile-more-item" data-testid={destination.moreTestId} onClick={() => destination.navigate("more")}><span className="mobile-more-item-icon-wrapper">{destination.icon}{destination.indicator && <span className="status-dot status-dot--pending mobile-more-item-icon-dot" aria-label={destination.indicatorLabel} />}</span><span>{label}</span>{destination.badge && destination.badge > 0 ? <span className="mobile-more-item-badge">{formatCount(destination.badge)}</span> : null}</button>;
};
return (
<>
<nav
ref={navRef}
className={`mobile-nav-bar${footerVisible ? " mobile-nav-bar--with-footer" : ""}${keyboardOpen ? " mobile-nav-bar--keyboard-open" : ""}`}
role="tablist"
aria-label={t("nav.primaryNavAriaLabel", "Primary navigation")}
>
{effectivePrimaryItems.map((item) => renderSelectableItem(item, "primary"))}
{topLevelPrimaryPluginViews.map((entry) => {
const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
const PluginIcon = getPluginDashboardViewNavIcon(entry);
return (
<button
key={`${entry.pluginId}:${entry.view.viewId}`}
type="button"
className={`mobile-nav-tab${view === pluginTaskView || (view === "graph" && entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph") ? " mobile-nav-tab--active" : ""}`}
data-testid={`mobile-nav-tab-plugin-${entry.pluginId}-${entry.view.viewId}`}
role="tab"
aria-selected={view === pluginTaskView || (view === "graph" && entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph")}
onClick={() => onChangeView(entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph" ? "graph" : pluginTaskView)}
>
<span className="mobile-nav-tab-icon-wrapper">
<PluginIcon />
</span>
<span className="mobile-nav-tab-label">{entry.view.label}</span>
</button>
);
})}
<button
type="button"
className={`mobile-nav-tab${isMoreActive ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-more"
role="tab"
aria-selected={false}
onClick={() => {
if (isMoreOpen) {
dismissMore();
} else {
setIsMoreOpen(true);
}
}}
>
<span className="mobile-nav-tab-icon-wrapper">
<MoreHorizontal />
{planningNeedsInput && view !== "planning" && !isMoreOpen && (
<span className="status-dot status-dot--pending mobile-nav-chat-unread-dot" aria-label={t("nav.planningNeedsInputAriaLabel", "Planning needs your input")} />
)}
</span>
<span className="mobile-nav-tab-label">{t("nav.more", "More")}</span>
</button>
</nav>
{isMoreOpen && (
<>
<div
className="mobile-more-sheet-backdrop"
onClick={() => dismissMore()}
/>
<div
ref={sheetRef}
className={`mobile-more-sheet${isSheetDragging ? " mobile-more-sheet--dragging" : ""}${hasSheetDragged ? " mobile-more-sheet--gesture-ready" : ""}`}
style={{ transform: `translateY(${dragOffset}px)` }}
onTouchStart={handleSheetTouchStart}
onTouchEnd={finishSheetDrag}
onTouchCancel={resetSheetDrag}
>
<div className="mobile-more-sheet-handle" aria-hidden="true" />
<div className="mobile-more-sheet-title">{t("nav.moreSheetTitle", "Navigate")}</div>
{shellConnectionControl ? (
<div className="mobile-more-shell-connection" data-testid="mobile-more-shell-connection">
{shellConnectionControl}
</div>
) : null}
<div className="mobile-more-split-row">
<button
type="button"
className="mobile-more-item mobile-more-split-primary"
data-testid="mobile-more-item-terminal"
onClick={() => handleMoreAction(onToggleTerminal)}
>
<Terminal />
<span>{t("nav.terminal", "Terminal")}</span>
</button>
<button
type="button"
className="mobile-more-split-toggle"
data-testid="mobile-more-terminal-split-toggle"
onClick={() => setIsScriptsSubmenuOpen((prev) => !prev)}
aria-expanded={isScriptsSubmenuOpen}
aria-haspopup="menu"
aria-label={t("nav.showScriptsAriaLabel", "Show scripts")}
>
<ChevronRight
size={14}
className={`mobile-more-chevron${isScriptsSubmenuOpen ? " mobile-more-chevron--open" : ""}`}
/>
</button>
</div>
{isScriptsSubmenuOpen && (
<div className="mobile-more-submenu" role="menu" aria-label={t("nav.scriptsSubmenuAriaLabel", "Scripts submenu")}>
{scriptsLoading ? (
<div className="mobile-more-submenu-loading" data-testid="mobile-more-scripts-loading">
<Loader2 className="animate-spin" />
<span>{t("nav.loadingScripts", "Loading scripts…")}</span>
</div>
) : scriptEntries.length > 0 ? (
<>
{scriptEntries.map(([name, command]) => (
<button
key={name}
type="button"
className="mobile-more-item mobile-more-subitem"
data-testid={`mobile-more-script-item-${name}`}
onClick={() => {
if (onRunScript) onRunScript(name, command);
dismissMore();
setIsScriptsSubmenuOpen(false);
}}
>
<Play />
<span>{name}</span>
</button>
))}
{onOpenScripts && (
<button
type="button"
className="mobile-more-item mobile-more-subitem mobile-more-subitem--manage"
data-testid="mobile-more-scripts-manage"
onClick={() => {
dismissMore();
setIsScriptsSubmenuOpen(false);
onOpenScripts();
}}
>
<FileCode />
<span>{t("nav.manageScripts", "Manage Scripts…")}</span>
</button>
)}
</>
) : (
onOpenScripts && (
<button
type="button"
className="mobile-more-item mobile-more-subitem"
data-testid="mobile-more-scripts-manage"
onClick={() => {
dismissMore();
setIsScriptsSubmenuOpen(false);
onOpenScripts();
}}
>
<FileCode />
<span>{t("nav.noScriptsAddOne", "No scripts — add one…")}</span>
</button>
)
)}
</div>
)}
{effectiveOmittedItems
.filter((item) => item !== "settings")
.map((item) => renderSelectableItem(item, "more"))}
{overflowPluginViews.map((entry) => {
const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
const PluginIcon = getPluginDashboardViewNavIcon(entry);
return (
<button
key={`${entry.pluginId}:${entry.view.viewId}`}
type="button"
className="mobile-more-item"
data-testid={`mobile-more-item-plugin-${entry.pluginId}-${entry.view.viewId}`}
onClick={() => handleMoreAction(() => onChangeView(entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph" ? "graph" : pluginTaskView))}
>
<PluginIcon />
<span>{entry.view.label}</span>
</button>
);
})}
<div className="mobile-more-separator" />
{/*
FNXC:Navigation 2026-07-17-15:43:
Mobile More-sheet pins Settings below the `mobile-more-separator` divider so it stays at the bottom of
the list (FN-8250), not inline in the middle. The omitted-items guard prevents a duplicate when Settings
is promoted to a primary footer tab.
*/}
{effectiveOmittedItems.includes("settings") && renderSelectableItem("settings", "more")}
</div>
</>
)}
</>
);
}