FN-8204: add configurable mobile footer quick actions

Add an ordered project setting for mobile footer quick-action destinations.

- Render selected destinations as primary mobile tabs and retain omitted choices in More.
- Provide settings controls, validation, translations, documentation, and regression coverage.
- Add a minor changeset for the published CLI package.

Files changed:
 .changeset/fn-8204-mobile-nav-primary-items.md     |   7 +
 docs/dashboard-guide.md                            |   4 +
 docs/settings-reference.md                         |   4 +
 .../src/__tests__/mobile-nav-primary-items.test.ts |  23 +++
 packages/core/src/index.ts                         |   9 ++
 packages/core/src/mobile-nav-primary-items.ts      |  55 +++++++
 packages/core/src/settings-schema.ts               |   1 +
 packages/core/src/types.ts                         |   7 +
 packages/dashboard/app/App.tsx                     |   2 +
 .../mobile-feature-access-regression.test.tsx      |  19 +++
 packages/dashboard/app/components/MobileNavBar.tsx | 159 +++++----------------
 .../app/components/__tests__/MobileNavBar.test.tsx |  16 +++
 .../app/components/settings/section-keys.ts        |   1 +
 .../settings/sections/GeneralSection.search.ts     |   9 ++
 .../settings/sections/GeneralSection.tsx           |  45 ++++++
 .../settings-default-descriptions.test.tsx         |   1 +
 packages/dashboard/app/hooks/useAppSettings.ts     |   5 +
 packages/i18n/locales/en/app.json                  |   4 +
 18 files changed, 244 insertions(+), 127 deletions(-)

Fusion-Task-Id: FN-8204

Fusion-Task-Lineage: 077de4c4-d281-401d-bddd-e5ce8e91d4ab

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-17 09:27:33 -07:00
parent c0d610d9d7
commit d6860b5b76
18 changed files with 244 additions and 127 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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"]);
});
});

View File

@@ -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,

View File

@@ -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<MobileNavSelectableItem, string> = {
"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<ProjectSettings, "mobileNavPrimaryItems">): ResolvedMobileNavPrimaryItems {
const selected = Array.isArray(settings?.mobileNavPrimaryItems) ? settings.mobileNavPrimaryItems : [];
const valid = new Set<string>(MOBILE_NAV_SELECTABLE_ITEMS);
const primaryItems = selected.reduce<MobileNavSelectableItem[]>((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)) };
}

View File

@@ -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.

View File

@@ -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.

View File

@@ -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")}

View File

@@ -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(
<MobileNavBar
{...createDefaultMobileNavProps()}
mobileNavPrimaryItems={["command-center", "tasks", "agents", "planning", "chat", "mailbox", "skills"]}
showSkillsTab={false}
experimentalFeatures={{ insights: false, memoryView: false }}
/>,
);
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(<MobileNavBar {...props} />);

View File

@@ -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" ? <Gauge /> : item === "tasks" ? <LayoutGrid /> : item === "agents" ? <Bot /> : item === "missions" ? <Target /> : item === "chat" ? <MessageSquare /> : item === "mailbox" ? <Mail /> : <Lightbulb />;
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 <button key={item} type="button" className={`mobile-nav-tab${active ? " mobile-nav-tab--active" : ""}`} data-testid={`mobile-nav-tab-${item}`} role="tab" aria-selected={active} onClick={navigate}><span className="mobile-nav-tab-icon-wrapper">{icon}{indicator && <span className="status-dot status-dot--pending mobile-nav-chat-unread-dot" aria-label={indicatorLabel} />}</span><span className="mobile-nav-tab-label">{label}</span>{badge > 0 && <span className="mobile-nav-tab-badge">{formatCount(badge)}</span>}</button>;
return <button key={item} type="button" className="mobile-more-item" data-testid={`mobile-more-item-${item}`} onClick={navigate}><span className="mobile-more-item-icon-wrapper">{icon}{indicator && <span className="status-dot status-dot--pending mobile-more-item-icon-dot" aria-label={indicatorLabel} />}</span><span>{label}</span>{badge > 0 && <span className="mobile-more-item-badge">{formatCount(badge)}</span>}</button>;
};
return (
<>
<nav
@@ -425,115 +454,7 @@ export function MobileNavBar({
role="tablist"
aria-label={t("nav.primaryNavAriaLabel", "Primary navigation")}
>
{/*
FNXC:Navigation 2026-06-22-01:40:
Dashboard (Command Center) is the first mobile tab, before Tasks, matching the desktop sidebar order.
*/}
<button
type="button"
className={`mobile-nav-tab${view === "command-center" ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-command-center"
role="tab"
aria-selected={view === "command-center"}
onClick={() => onChangeView("command-center")}
>
<span className="mobile-nav-tab-icon-wrapper">
<Gauge />
</span>
<span className="mobile-nav-tab-label">{t("nav.commandCenter", "Dashboard")}</span>
</button>
<button
type="button"
className={`mobile-nav-tab${view === "board" || view === "list" ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-tasks"
role="tab"
aria-selected={view === "board" || view === "list"}
onClick={() => {
// If already on a tasks view, stay there; otherwise go to board
if (view === "board" || view === "list") {
onChangeView(view);
} else {
onChangeView("board");
}
}}
>
<span className="mobile-nav-tab-icon-wrapper">
<LayoutGrid />
</span>
<span className="mobile-nav-tab-label">{t("nav.tasks", "Tasks")}</span>
</button>
<button
type="button"
className={`mobile-nav-tab${view === "agents" ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-agents"
role="tab"
aria-selected={view === "agents"}
onClick={() => onChangeView("agents")}
>
<span className="mobile-nav-tab-icon-wrapper">
<Bot />
</span>
<span className="mobile-nav-tab-label">{t("nav.agents", "Agents")}</span>
</button>
<button
type="button"
className={`mobile-nav-tab${view === "missions" ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-missions"
role="tab"
aria-selected={view === "missions"}
onClick={() => onChangeView("missions")}
>
<span className="mobile-nav-tab-icon-wrapper">
<Target />
</span>
<span className="mobile-nav-tab-label">{t("nav.missions", "Missions")}</span>
</button>
<button
type="button"
className={`mobile-nav-tab${view === "chat" ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-chat"
role="tab"
aria-selected={view === "chat"}
onClick={() => onChangeView("chat")}
>
<span className="mobile-nav-tab-icon-wrapper">
<MessageSquare />
{chatHasUnreadResponse && view !== "chat" && (
<span className="status-dot status-dot--pending mobile-nav-chat-unread-dot" aria-label={t("nav.chatUnreadAriaLabel", "Unread chat response")} />
)}
</span>
<span className="mobile-nav-tab-label">{t("nav.chat", "Chat")}</span>
</button>
{/*
FNXC:Navigation 2026-06-19-12:30:
Mailbox is a top-level mobile tab only and must not be duplicated in the three-dot More sheet; Todos lives only in the three-dot overflow/More menu, never the main tab list.
Keep unread and pending-approval indicators on this surviving Mailbox tab so removing the More-sheet duplicate does not hide mailbox state.
*/}
<button
type="button"
className={`mobile-nav-tab${view === "mailbox" ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-mailbox"
role="tab"
aria-selected={view === "mailbox"}
onClick={() => onChangeView("mailbox")}
>
<span className="mobile-nav-tab-icon-wrapper">
<Mail />
{mailboxPendingApprovalCount > 0 && view !== "mailbox" && (
<span className="status-dot status-dot--pending mobile-nav-chat-unread-dot" aria-label={t("nav.mailboxPendingAriaLabel", "Pending approvals")} />
)}
</span>
<span className="mobile-nav-tab-label">{t("nav.mailbox", "Mailbox")}</span>
{mailboxUnreadCount > 0 && (
<span className="mobile-nav-tab-badge">{formatCount(mailboxUnreadCount)}</span>
)}
</button>
{primaryItems.map((item) => renderSelectableItem(item, "primary"))}
{showSkillsTopLevel && (
<button
@@ -737,23 +658,7 @@ export function MobileNavBar({
<span>{t("nav.files", "Files")}</span>
</button>
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-planning"
onClick={() => handleMoreAction(planningHandler)}
>
<span className="mobile-more-item-icon-wrapper">
<Lightbulb />
{planningNeedsInput && (
<span className="status-dot status-dot--pending mobile-more-item-icon-dot" aria-label={t("nav.planningNeedsInputAriaLabel", "Planning needs your input")} />
)}
</span>
<span>{t("nav.planning", "Planning")}</span>
{activePlanningSessionCount > 0 && (
<span className="mobile-more-item-badge">{formatCount(activePlanningSessionCount)}</span>
)}
</button>
{omittedItems.map((item) => renderSelectableItem(item, "more"))}
<button
type="button"

View File

@@ -152,6 +152,22 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-more-item-skills")).toBeDefined();
});
it("promotes Planning and routes demoted Missions to More without an empty tab", () => {
const { container } = render(<MobileNavBar {...createDefaultProps()} mobileNavPrimaryItems={["command-center", "tasks", "agents", "planning", "chat", "mailbox"]} />);
expect(screen.getByTestId("mobile-nav-tab-planning")).toBeInTheDocument();
expect(screen.queryByTestId("mobile-nav-tab-missions")).toBeNull();
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.getByTestId("mobile-more-item-missions")).toBeInTheDocument();
expect(container.querySelector('[data-testid="mobile-nav-tab-missions"]')).toBeNull();
});
it("never promotes overflow-only ids and always keeps More", () => {
render(<MobileNavBar {...createDefaultProps()} mobileNavPrimaryItems={["settings", "planning"]} />);
expect(screen.queryByTestId("mobile-nav-tab-settings")).toBeNull();
expect(screen.getByTestId("mobile-nav-tab-planning")).toBeInTheDocument();
expect(screen.getByTestId("mobile-nav-tab-more")).toBeInTheDocument();
});
it("does not render legacy roadmaps tab", () => {
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{}} />);
expect(screen.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull();

View File

@@ -69,6 +69,7 @@ const PROJECT_SECTION_KEYS: Record<string, readonly string[]> = {
"ephemeralAgentsEnabled",
"sessionAdvisorEnabledByDefault",
"mailAutoCleanupDays",
"mobileNavPrimaryItems",
"operationalLogRetentionDays",
"quickChatButtonMode",
"quickChatCloseOnOutsideClick",

View File

@@ -47,6 +47,15 @@ export const generalSearchEntries: SettingsSearchEntry[] = [
"When enabled, slash-prefixed paths such as /tmp can be opened in the workspace file browser. Windows drive-letter paths remain blocked, and other path validators are unchanged. Default: disabled.",
keywords: ["outside workspace", "root paths"],
},
{
sectionId: "general",
key: "mobileNavPrimaryItems",
labelKey: "settings.general.mobileNavPrimaryItems",
labelFallback: "Mobile footer quick actions",
helpKey: "settings.general.mobileNavPrimaryItemsHint",
helpFallback: "Default: Dashboard, Tasks, Agents, Missions, Chat, Mailbox. Unselected destinations remain in More.",
keywords: ["mobile", "footer", "navigation", "planning", "more"],
},
{
sectionId: "general",
key: "quickChatButtonMode",

View File

@@ -1,5 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { DEPRECATED_BUILTIN_WORKFLOW_IDS, isLocale, SUPPORTED_LOCALES, type WorkflowDefinition } from "@fusion/core";
import { DEFAULT_MOBILE_NAV_PRIMARY_ITEMS, MAX_MOBILE_NAV_PRIMARY_ITEMS, MOBILE_NAV_SELECTABLE_ITEMS } from "../../../../../core/src/mobile-nav-primary-items";
import { SettingsFieldRow } from "../SettingsFieldRow";
import { SettingsToggleRow } from "../SettingsToggleRow";
import { SettingsSelectRow } from "../SettingsSelectRow";
import { SettingsNumberRow } from "../SettingsNumberRow";
@@ -289,6 +291,49 @@ export function GeneralSection({ form, setForm, projectId, addToast, prefixError
return { ...f, quickChatButtonMode: mode, showQuickChatFAB: mode === "floating" };
})}
/>
{/*
FNXC:Navigation 2026-07-17-00:00:
The Settings control exposes only the fixed seven-item quick-action universe. Checkboxes choose
destinations and the adjacent controls preserve their selected order; core still sanitizes persisted
values so unselected destinations remain reachable through More and its trailing tab cannot be removed.
*/}
<SettingsFieldRow
htmlFor="mobileNavPrimaryItems"
label={t("settings.general.mobileNavPrimaryItems", "Mobile footer quick actions")}
help={t("settings.general.mobileNavPrimaryItemsHint", "Default: Dashboard, Tasks, Agents, Missions, Chat, Mailbox. Unselected destinations remain in the More menu.")}
scope="project"
>
<div role="group" aria-label={t("settings.general.mobileNavPrimaryItems", "Mobile footer quick actions")}>
{MOBILE_NAV_SELECTABLE_ITEMS.map((item) => {
const selectedItems = Array.isArray(form.mobileNavPrimaryItems)
? form.mobileNavPrimaryItems
: DEFAULT_MOBILE_NAV_PRIMARY_ITEMS;
const selectedIndex = selectedItems.indexOf(item);
const selected = selectedIndex >= 0;
const label = t(`nav.${item === "command-center" ? "commandCenter" : item}`, item);
const updateItems = (nextItems: string[]) => setForm((current) => ({ ...current, mobileNavPrimaryItems: nextItems }));
return (
<div key={item}>
<label className="checkbox-label">
<input
type="checkbox"
checked={selected}
disabled={!selected && selectedItems.length >= MAX_MOBILE_NAV_PRIMARY_ITEMS}
onChange={(event) => updateItems(event.target.checked ? [...selectedItems, item] : selectedItems.filter((selectedItem) => selectedItem !== item))}
/>
<span>{label}</span>
</label>
{selected && (
<>
<button type="button" className="btn btn-icon" disabled={selectedIndex === 0} aria-label={t("settings.general.moveNavItemEarlier", "Move {{item}} earlier", { item: label })} onClick={() => updateItems(selectedItems.map((selectedItem, index) => index === selectedIndex - 1 ? item : index === selectedIndex ? selectedItems[index - 1] : selectedItem))}>↑</button>
<button type="button" className="btn btn-icon" disabled={selectedIndex === selectedItems.length - 1} aria-label={t("settings.general.moveNavItemLater", "Move {{item}} later", { item: label })} onClick={() => updateItems(selectedItems.map((selectedItem, index) => index === selectedIndex + 1 ? item : index === selectedIndex ? selectedItems[index + 1] : selectedItem))}>↓</button>
</>
)}
</div>
);
})}
</div>
</SettingsFieldRow>
{/*
FNXC:ChatModal 2026-06-28-00:00:
Operators need a Settings > General toggle for Quick Chat outside-click dismissal because accidental board clicks can otherwise close active chat context. Default checked preserves the shipped FN-7152 interaction.

View File

@@ -253,6 +253,7 @@ const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
mailAutoCleanupDays: "general.deleteInboxOutboxMessagesOlderThanThisMany",
operationalLogRetentionDays: "general.loweringThisWindowMeansReliabilityMetricsChartsAnd",
quickChatButtonMode: "general.quickChatLauncherHint",
mobileNavPrimaryItems: "general.mobileNavPrimaryItemsHint",
quickChatCloseOnOutsideClick: "general.quickChatCloseOnOutsideClickHint",
showTaskChatsInCommonFeed: "general.showTaskChatsInCommonFeedHint",
taskPrefix: "general.prefixForNewTaskIDsEGKB",

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchConfig, fetchSettings, updateSettings, updateGlobalSettings } from "../api";
import type { GlobalSettings, ProjectSettings } from "@fusion/core";
import { resolveMobileNavPrimaryItems } from "../../../core/src/mobile-nav-primary-items";
import type { ModelPricingOverrides } from "../../../core/src/model-pricing";
import { setAutoReloadEnabled } from "../versionCheck";
import { DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS, resolveDashboardKeyboardShortcuts, type DashboardKeyboardShortcutMap } from "../utils/keyboardShortcuts";
@@ -34,6 +35,7 @@ export interface UseAppSettingsResult {
modelPricingOverrides?: ModelPricingOverrides;
taskDetailChatFirst: boolean;
quickChatButtonMode: QuickChatButtonMode;
mobileNavPrimaryItems: string[];
quickChatCloseOnOutsideClick: boolean;
dashboardKeyboardShortcuts: Required<DashboardKeyboardShortcutMap>;
dismissModalsOnOutsideClick: boolean;
@@ -93,6 +95,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
const [modelPricingOverrides, setModelPricingOverrides] = useState<ModelPricingOverrides | undefined>(undefined);
const [taskDetailChatFirst, setTaskDetailChatFirst] = useState(false);
const [quickChatButtonMode, setQuickChatButtonMode] = useState<QuickChatButtonMode>("off");
const [mobileNavPrimaryItems, setMobileNavPrimaryItems] = useState<string[]>(() => resolveMobileNavPrimaryItems().primaryItems);
const [quickChatCloseOnOutsideClick, setQuickChatCloseOnOutsideClick] = useState(true);
const [dashboardKeyboardShortcuts, setDashboardKeyboardShortcuts] = useState<Required<DashboardKeyboardShortcutMap>>(DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS);
const [dismissModalsOnOutsideClick, setDismissModalsOnOutsideClick] = useState(false);
@@ -161,6 +164,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
? "floating"
: "off";
setQuickChatButtonMode(nextQuickChatButtonMode);
setMobileNavPrimaryItems(resolveMobileNavPrimaryItems(settings).primaryItems);
setQuickChatCloseOnOutsideClick(settings.quickChatCloseOnOutsideClick !== false);
setDashboardKeyboardShortcuts(resolveDashboardKeyboardShortcuts((settings as GlobalSettings).dashboardKeyboardShortcuts));
setDismissModalsOnOutsideClick(settings.dismissModalsOnOutsideClick === true);
@@ -349,6 +353,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
modelPricingOverrides,
taskDetailChatFirst,
quickChatButtonMode,
mobileNavPrimaryItems,
quickChatCloseOnOutsideClick,
dashboardKeyboardShortcuts,
dismissModalsOnOutsideClick,

View File

@@ -5868,6 +5868,10 @@
"hardCapOnTheSynthesizedEarlierRoomContext": "Hard cap on the synthesized \"Earlier room context\" summary block. Default: 3000.",
"learnMore": "Learn more",
"loweringThisWindowMeansReliabilityMetricsChartsAnd": " Lowering this window means Reliability metrics/charts and the Activity feed will not show history older than the selected range. Per-task task detail history is unaffected. Default: 30 days. ",
"mobileNavPrimaryItems": "Mobile footer quick actions",
"mobileNavPrimaryItemsHint": "Default: Dashboard, Tasks, Agents, Missions, Chat, Mailbox. Unselected destinations remain in the More menu.",
"moveNavItemEarlier": "Move {{item}} earlier",
"moveNavItemLater": "Move {{item}} later",
"newTasksInheritThisCustomWorkflowsStepsOverridable": "New tasks inherit this custom workflow's steps (overridable per task). No default — unset (built-in default workflow).",
"numberOfMostRecentChatRoomMessagesKept": "Number of most-recent chat-room messages kept verbatim in the responder transcript. Older messages are compacted into a summary block. Default: 25.",
"off": "Off",