diff --git a/.changeset/fn-8204-mobile-nav-primary-items.md b/.changeset/fn-8204-mobile-nav-primary-items.md new file mode 100644 index 0000000000..f8c9eac4be --- /dev/null +++ b/.changeset/fn-8204-mobile-nav-primary-items.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Choose which quick-action tabs appear in the mobile footer nav. +category: feature +dev: Adds project setting `mobileNavPrimaryItems` (ordered list of the seven selectable canonical nav-item ids: command-center, tasks, agents, missions, chat, mailbox, planning); MobileNavBar renders primary tabs from it and routes omitted selectable destinations to the More sheet. Default reproduces the prior order. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 0f7acbfd2f..a597ecdab6 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -2034,3 +2034,7 @@ If the endpoint is unavailable on the running dashboard build, the response will In **Settings → Notifications**, enable **Agent clarification** to let Planning Mode pause when the planner needs an answer. The Planning Mode advanced settings include a per-session override, initialized from that global preference. With clarification disabled, proactive questions are redirected to a final plan summary instead of holding the session; the final summary deepening checkpoint is unchanged. When enabled, a proactive question holds the planner at `awaiting_input`, sends the configured `planning-awaiting-input` ntfy event, and delivers a dashboard mailbox message that links the operator back to planner chat. Mailbox delivery does not depend on ntfy configuration and is deduplicated by session/question across restarts. + +### Mobile footer quick actions + +In **Settings → General**, choose up to six Mobile footer quick actions with the ordered checkbox list. Planning can replace a default destination; use the adjacent earlier/later controls to set the footer order. Any unselected quick action moves to More, so it remains reachable. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 89d9a8a11e..ac5269c512 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -1773,3 +1773,7 @@ Values are project-scoped and finite values are floored; count/backoff must be a Escalation is enabled only when the toggle is true and either a complete provider/model pair or a node ID is configured. It is single-shot: after FN-7996 exhausts same-model retries, Fusion persists the override and tries once before the existing terminal park. The alternate model enters the [model-selection hierarchy](#model-selection-hierarchy) as a task-level override; a node target enters `resolveEffectiveNode` as a task-level routing override and is requeued so scheduler routing is recalculated. This remains opt-in by default to avoid unexpected model cost or execution behavior. Column-agent overrides still govern their sessions and can supersede a task-level model target. | `triageDuplicateResolution` | `"prompt" \| "keep" \| "delete"` | `"prompt"` | Controls `DUPLICATE: FN-NNNN` markers emitted during triage. **prompt** flags and system-pauses the task for an operator Keep/Delete decision; the existing decision banner links to the canonical task. **keep** dismisses the marker and replans a real task. **delete** restores legacy auto-delete behavior. | + +### `mobileNavPrimaryItems` + +Project-scoped ordered list of mobile footer quick actions. The default is `command-center`, `tasks`, `agents`, `missions`, `chat`, `mailbox`. The only selectable ids are those six plus `planning`; unknown, `more`, and overflow-only ids are ignored. Omitted selectable destinations remain reachable in the More sheet, whose trailing footer tab is always present. diff --git a/packages/core/src/__tests__/mobile-nav-primary-items.test.ts b/packages/core/src/__tests__/mobile-nav-primary-items.test.ts new file mode 100644 index 0000000000..72a3436f9d --- /dev/null +++ b/packages/core/src/__tests__/mobile-nav-primary-items.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_MOBILE_NAV_PRIMARY_ITEMS, resolveMobileNavPrimaryItems } from "../mobile-nav-primary-items.js"; + +describe("resolveMobileNavPrimaryItems", () => { + it("uses the existing six-tab order for unset or empty values", () => { + expect(resolveMobileNavPrimaryItems()).toMatchObject({ primaryItems: DEFAULT_MOBILE_NAV_PRIMARY_ITEMS }); + expect(resolveMobileNavPrimaryItems({ mobileNavPrimaryItems: [] })).toMatchObject({ primaryItems: DEFAULT_MOBILE_NAV_PRIMARY_ITEMS }); + }); + + it("accepts planning, preserves order, and routes omitted destinations to More", () => { + const resolved = resolveMobileNavPrimaryItems({ mobileNavPrimaryItems: ["tasks", "planning", "agents"] }); + expect(resolved.primaryItems).toEqual(["tasks", "planning", "agents"]); + expect(resolved.omittedItems).toEqual(["command-center", "missions", "chat", "mailbox"]); + }); + + it("drops overflow-only and unknown ids, deduplicates, and clamps footer tabs", () => { + const resolved = resolveMobileNavPrimaryItems({ + mobileNavPrimaryItems: ["settings", "tasks", "more", "documents", "tasks", "agents", "missions", "chat", "mailbox", "planning", "unknown"], + }); + expect(resolved.primaryItems).toEqual(["tasks", "agents", "missions", "chat", "mailbox", "planning"]); + expect(resolved.omittedItems).toEqual(["command-center"]); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 77c9143f5e..807d41c317 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1974,6 +1974,15 @@ export type { } from "./research-types.js"; export { isExperimentalFeatureEnabled, GRAPH_NATIVE_POST_MERGE_FLAG } from "./experimental-features.js"; +export { + DEFAULT_MOBILE_NAV_PRIMARY_ITEMS, + MAX_MOBILE_NAV_PRIMARY_ITEMS, + MOBILE_NAV_SELECTABLE_ITEMS, + MOBILE_NAV_SELECTABLE_ITEM_LABEL_KEYS, + resolveMobileNavPrimaryItems, + type MobileNavSelectableItem, + type ResolvedMobileNavPrimaryItems, +} from "./mobile-nav-primary-items.js"; export { POST_MERGE_VERIFICATION_GROUP_ID, postMergeOptionalGroupNode, diff --git a/packages/core/src/mobile-nav-primary-items.ts b/packages/core/src/mobile-nav-primary-items.ts new file mode 100644 index 0000000000..960bdab668 --- /dev/null +++ b/packages/core/src/mobile-nav-primary-items.ts @@ -0,0 +1,55 @@ +import type { ProjectSettings } from "./types.js"; + +/** The only destinations that may be promoted into the mobile footer. */ +export const MOBILE_NAV_SELECTABLE_ITEMS = [ + "command-center", + "tasks", + "agents", + "missions", + "chat", + "mailbox", + "planning", +] as const; + +export type MobileNavSelectableItem = (typeof MOBILE_NAV_SELECTABLE_ITEMS)[number]; + +/** Stable i18n keys for settings controls that list selectable destinations. */ +export const MOBILE_NAV_SELECTABLE_ITEM_LABEL_KEYS: Record = { + "command-center": "nav.commandCenter", + tasks: "nav.tasks", + agents: "nav.agents", + missions: "nav.missions", + chat: "nav.chat", + mailbox: "nav.mailbox", + planning: "nav.planning", +}; + +export const DEFAULT_MOBILE_NAV_PRIMARY_ITEMS: MobileNavSelectableItem[] = [ + "command-center", "tasks", "agents", "missions", "chat", "mailbox", +]; + +export const MAX_MOBILE_NAV_PRIMARY_ITEMS = 6; + +export interface ResolvedMobileNavPrimaryItems { + primaryItems: MobileNavSelectableItem[]; + omittedItems: MobileNavSelectableItem[]; +} + +/* +FNXC:Navigation 2026-07-17-00:00: +Mobile footer customization is intentionally limited to these seven quick-action ids. Invalid, +overflow-only, and `more` ids cannot become footer tabs; omitted selectable destinations remain +reachable in More, and More itself is always rendered separately as the trailing tab. +*/ +export function resolveMobileNavPrimaryItems(settings?: Pick): ResolvedMobileNavPrimaryItems { + const selected = Array.isArray(settings?.mobileNavPrimaryItems) ? settings.mobileNavPrimaryItems : []; + const valid = new Set(MOBILE_NAV_SELECTABLE_ITEMS); + const primaryItems = selected.reduce((items, id) => { + if (valid.has(id) && !items.includes(id as MobileNavSelectableItem) && items.length < MAX_MOBILE_NAV_PRIMARY_ITEMS) { + items.push(id as MobileNavSelectableItem); + } + return items; + }, []); + const resolved = primaryItems.length > 0 ? primaryItems : [...DEFAULT_MOBILE_NAV_PRIMARY_ITEMS]; + return { primaryItems: resolved, omittedItems: MOBILE_NAV_SELECTABLE_ITEMS.filter((id) => !resolved.includes(id)) }; +} diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index a5be16ee85..49cf57b5e2 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -712,6 +712,7 @@ export const DEFAULT_PROJECT_SETTINGS = { reflectionAfterTask: true, // reviewHandoffPolicy MOVED to workflow settings (U4) — see MOVED_SETTINGS_KEYS. quickChatButtonMode: "off", + mobileNavPrimaryItems: ["command-center", "tasks", "agents", "missions", "chat", "mailbox"], /* FNXC:ChatModal 2026-06-28-00:00: Quick Chat outside-click dismissal remains default-on for upgrades, but it is now a project setting so operators can disable accidental board-click closes. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 2688f96662..a7fe5df6eb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -4250,6 +4250,13 @@ export interface ProjectSettings { reviewHandoffPolicy?: "disabled" | "comment-triggered" | "always"; /** Quick Chat launcher placement. "floating" shows the draggable FAB, "footer" shows a footer button, "off" hides both. */ quickChatButtonMode?: "floating" | "footer" | "off"; + /* + * FNXC:Navigation 2026-07-17-00:00: + * Ordered quick-action ids shown before the always-present mobile More tab. Only command-center, + * tasks, agents, missions, chat, mailbox, and planning are eligible; unset falls back to the + * default order, invalid/overflow-only ids (including more) are ignored, and omitted ids stay in More. + */ + mobileNavPrimaryItems?: string[]; /** * FNXC:ChatModal 2026-06-28-00:00: * Outside-click dismissal of Quick Chat is now user-configurable; default true preserves the prior always-on behavior from FN-7152. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 789650b037..a8aa1851cd 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -685,6 +685,7 @@ function AppInner() { modelPricingOverrides, taskDetailChatFirst, quickChatButtonMode, + mobileNavPrimaryItems, quickChatCloseOnOutsideClick, dashboardKeyboardShortcuts, dismissModalsOnOutsideClick, @@ -1772,6 +1773,7 @@ function AppInner() { footerVisible={viewMode === "project" && !!currentProject} modalOpen={modalManager.anyModalOpen} keyboardOpen={mobileNavKeyboardOpen} + mobileNavPrimaryItems={mobileNavPrimaryItems} onOpenSettings={openSettingsWithNav} onOpenActivityLog={openActivityLogWithNav} onOpenMailbox={() => handleTaskViewChange("mailbox")} diff --git a/packages/dashboard/app/__tests__/mobile-feature-access-regression.test.tsx b/packages/dashboard/app/__tests__/mobile-feature-access-regression.test.tsx index 1166033fb1..2752880afd 100644 --- a/packages/dashboard/app/__tests__/mobile-feature-access-regression.test.tsx +++ b/packages/dashboard/app/__tests__/mobile-feature-access-regression.test.tsx @@ -209,6 +209,25 @@ describe("Mobile Feature Access Regression Guard", () => { expect(screen.getByTestId("mobile-more-item-settings")).toBeDefined(); }); + it("keeps every configurable destination reachable when a custom footer omits one", () => { + render( + , + ); + + expect(screen.queryByTestId("mobile-nav-tab-missions")).toBeNull(); + expect(screen.queryByTestId("mobile-nav-tab-skills")).toBeNull(); + expect(screen.getByTestId("mobile-nav-tab-more")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("mobile-nav-tab-more")); + expect(screen.getByTestId("mobile-more-item-missions")).toBeInTheDocument(); + expect(screen.queryByTestId("mobile-more-item-skills")).toBeNull(); + }); + it("reliability is no longer a mobile More item and is reached via Command Center", () => { const props = createDefaultMobileNavProps(); render(); diff --git a/packages/dashboard/app/components/MobileNavBar.tsx b/packages/dashboard/app/components/MobileNavBar.tsx index b9e14da30c..2ac42681a7 100644 --- a/packages/dashboard/app/components/MobileNavBar.tsx +++ b/packages/dashboard/app/components/MobileNavBar.tsx @@ -38,6 +38,7 @@ 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/mobile-nav-primary-items"; export interface PublishedMobileNavHeightInput { navOffsetHeight: number; @@ -116,6 +117,8 @@ export interface MobileNavBarProps { }; pluginDashboardViews?: PluginDashboardViewEntry[]; shellConnectionControl?: ReactNode; + /** Ordered quick-action tabs; invalid values resolve to the safe default. */ + mobileNavPrimaryItems?: string[]; } function GitHubLogo({ size = 20 }: { size?: number }) { @@ -167,6 +170,7 @@ export function MobileNavBar({ experimentalFeatures, pluginDashboardViews = [], shellConnectionControl, + mobileNavPrimaryItems, }: MobileNavBarProps) { const { t } = useTranslation("app"); const mode = useViewportMode(); @@ -402,8 +406,10 @@ export function MobileNavBar({ .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 }); const isMoreActive = - view === "documents" + omittedItems.some((item) => (item === "tasks" ? view === "board" || view === "list" : view === item)) + || view === "documents" || (Boolean(experimentalFeatures?.evalsView) && view === "evals") || (Boolean(experimentalFeatures?.goalsView) && view === "goalsView") || view === "research" @@ -417,6 +423,29 @@ export function MobileNavBar({ || view === "graph" || (isPluginViewId(view) && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view)); + /* + FNXC:Navigation 2026-07-17-00:00: + A configured quick-action destination renders exactly once: as a footer tab or as a More-sheet item. + This preserves its handlers and indicators while ensuring invalid settings cannot strand navigation. + */ + const renderSelectableItem = (item: MobileNavSelectableItem, surface: "primary" | "more") => { + const isPrimary = surface === "primary"; + const active = item === "tasks" ? view === "board" || view === "list" : view === item; + const navigate = () => { + if (item === "tasks") onChangeView(view === "board" || view === "list" ? view : "board"); + else if (item === "planning") { if (isPrimary) planningHandler?.(); else handleMoreAction(planningHandler); } + else if (isPrimary) onChangeView(item); + else handleMoreAction(() => onChangeView(item)); + }; + const icon = item === "command-center" ? : item === "tasks" ? : item === "agents" ? : item === "missions" ? : item === "chat" ? : item === "mailbox" ? : ; + const label = t(`nav.${item === "command-center" ? "commandCenter" : item}`, item === "command-center" ? "Dashboard" : item[0].toUpperCase() + item.slice(1)); + const indicator = (item === "chat" && chatHasUnreadResponse && !active) || (item === "mailbox" && mailboxPendingApprovalCount > 0 && !active) || (item === "planning" && planningNeedsInput && !active); + const badge = item === "mailbox" ? mailboxUnreadCount : item === "planning" ? activePlanningSessionCount : 0; + const indicatorLabel = item === "chat" ? t("nav.chatUnreadAriaLabel", "Unread chat response") : item === "mailbox" ? t("nav.mailboxPendingAriaLabel", "Pending approvals") : t("nav.planningNeedsInputAriaLabel", "Planning needs your input"); + if (isPrimary) return ; + return ; + }; + return ( <>