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:
7
.changeset/fn-8204-mobile-nav-primary-items.md
Normal file
7
.changeset/fn-8204-mobile-nav-primary-items.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
23
packages/core/src/__tests__/mobile-nav-primary-items.test.ts
Normal file
23
packages/core/src/__tests__/mobile-nav-primary-items.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
55
packages/core/src/mobile-nav-primary-items.ts
Normal file
55
packages/core/src/mobile-nav-primary-items.ts
Normal 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)) };
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -69,6 +69,7 @@ const PROJECT_SECTION_KEYS: Record<string, readonly string[]> = {
|
||||
"ephemeralAgentsEnabled",
|
||||
"sessionAdvisorEnabledByDefault",
|
||||
"mailAutoCleanupDays",
|
||||
"mobileNavPrimaryItems",
|
||||
"operationalLogRetentionDays",
|
||||
"quickChatButtonMode",
|
||||
"quickChatCloseOnOutsideClick",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user