FN-8352: promote ideation to top-level navigation
Make ideation independently accessible from desktop and mobile navigation. - Add an Ideation view backed by the Command Center panel and view-state routing. - Surface Ideation in desktop/sidebar and mobile primary navigation, with coverage for navigation and rendering. - Document the experimental setting and add a minor changeset. Files changed: .changeset/fn-8352-ideation-top-level-view.md | 7 ++++ docs/dashboard-guide.md | 14 +++++++ docs/settings-reference.md | 3 +- .../src/__tests__/mobile-nav-primary-items.test.ts | 10 ++++- packages/core/src/mobile-nav-primary-items.ts | 17 +++++++- packages/dashboard/app/App.tsx | 16 ++++++- packages/dashboard/app/components/Header.tsx | 21 +++++++-- .../dashboard/app/components/LeftSidebarNav.tsx | 8 +++- packages/dashboard/app/components/MobileNavBar.tsx | 2 + .../dashboard/app/components/SettingsModal.tsx | 6 +++ .../app/components/__tests__/App.test.tsx | 20 +++++++++ .../app/components/__tests__/Header.test.tsx | 15 +++++++ .../components/__tests__/LeftSidebarNav.test.tsx | 5 +++ .../app/components/__tests__/MobileNavBar.test.tsx | 43 +++++++++++++++--- .../components/command-center/CommandCenter.tsx | 12 +++--- .../__tests__/CommandCenter.test.tsx | 7 ++-- .../__tests__/IdeationPanel.test.tsx | 49 ++++++++++++++++++++++ .../app/components/dashboard/MainContent.tsx | 13 ++++++ .../dashboard/app/components/dashboard/types.ts | 1 + .../settings/sections/GeneralSection.tsx | 10 ++--- packages/dashboard/app/hooks/useViewState.ts | 8 +++- 21 files changed, 256 insertions(+), 31 deletions(-) Fusion-Task-Id: FN-8352 Fusion-Task-Lineage: e7791119-2fe5-4e68-b669-0e5bd8df9b49 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8352-ideation-top-level-view.md
Normal file
7
.changeset/fn-8352-ideation-top-level-view.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Ideation is now a top-level experimental sidebar/mobile view instead of a Command Center tab.
|
||||||
|
category: feature
|
||||||
|
dev: `experimentalFeatures.ideationView` is default-off; mobile keeps Ideation More-only, redirects disabled deep links to Board, and removes the Command Center tab.
|
||||||
@@ -1024,6 +1024,20 @@ Navigation:
|
|||||||
|
|
||||||
For the full research workflow, provider setup, CLI commands, API reference, and agent integration, see the canonical [Research guide](./research.md).
|
For the full research workflow, provider setup, CLI commands, API reference, and agent integration, see the canonical [Research guide](./research.md).
|
||||||
|
|
||||||
|
## Ideation View
|
||||||
|
|
||||||
|
<!-- FNXC:Navigation 2026-08-01-00:00: FN-8352 promotes Ideation from Command Center into one default-off experimental top-level destination. Desktop uses the sidebar (or Header More fallback when the sidebar is opted out), while mobile always keeps it in More rather than a configurable footer tab. -->
|
||||||
|
Ideation is a standalone dashboard surface for capturing divergent candidates and converging one into the Mission hierarchy.
|
||||||
|
|
||||||
|
> Available only when `experimentalFeatures.ideationView` is enabled. The flag is off by default and is managed in **Settings → Experimental Features**.
|
||||||
|
|
||||||
|
Navigation:
|
||||||
|
- Desktop/tablet: **Left sidebar → Ideation**, or the Header **More views** menu when the desktop sidebar is opted out
|
||||||
|
- Mobile: **More** sheet → **Ideation**
|
||||||
|
- Mobile footer customization never promotes Ideation to a primary tab, including when older persisted settings list it
|
||||||
|
|
||||||
|
The view replaces the former **Command Center → Ideation** tab. It preserves the same empty, populated, converged, and request-error states, so sessions and Mission handoff evidence remain in one canonical surface.
|
||||||
|
|
||||||
## Files Modal
|
## Files Modal
|
||||||
|
|
||||||
The Files modal provides a workspace-aware file browser and editor.
|
The Files modal provides a workspace-aware file browser and editor.
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio
|
|||||||
| `researchGlobalMaxSearchResults` | `number` | `undefined` | Maximum search results per provider query. |
|
| `researchGlobalMaxSearchResults` | `number` | `undefined` | Maximum search results per provider query. |
|
||||||
| `researchGlobalFetchTimeoutMs` | `number` | `30000` | Timeout for individual HTTP fetches in milliseconds. |
|
| `researchGlobalFetchTimeoutMs` | `number` | `30000` | Timeout for individual HTTP fetches in milliseconds. |
|
||||||
| `researchGlobalUserAgent` | `string` | `"FusionResearchBot/1.0"` | User-Agent header for HTTP requests made by research providers. |
|
| `researchGlobalUserAgent` | `string` | `"FusionResearchBot/1.0"` | User-Agent header for HTTP requests made by research providers. |
|
||||||
| `experimentalFeatures` | `Record<string, boolean>` | `{}` | Global-scoped experimental feature flags. Includes `experimentalFeatures.researchView`, which gates all Research surfaces and tools (dashboard view, engine task-session tools, and CLI `fn_research_*` tools), and `experimentalFeatures.evalsView`, which gates Evals surfaces (dashboard view, Settings → Scheduled Evals, and scheduled-eval cron execution). |
|
| `experimentalFeatures` | `Record<string, boolean>` | `{}` | Global-scoped experimental feature flags. Includes `experimentalFeatures.researchView`, which gates all Research surfaces and tools (dashboard view, engine task-session tools, and CLI `fn_research_*` tools); `experimentalFeatures.evalsView`, which gates Evals surfaces (dashboard view, Settings → Scheduled Evals, and scheduled-eval cron execution); and default-off `experimentalFeatures.ideationView`, which gates the top-level Ideation view (desktop sidebar/Header fallback and mobile More only). |
|
||||||
| `remoteAccess` | `RemoteAccessSettings` | `{ activeProvider: null, providers: {...}, tokenStrategy: {...}, lifecycle: {...} }` | Global-scoped remote access provider + token strategy configuration used by Remote Access routes and tunnel lifecycle controls. |
|
| `remoteAccess` | `RemoteAccessSettings` | `{ activeProvider: null, providers: {...}, tokenStrategy: {...}, lifecycle: {...} }` | Global-scoped remote access provider + token strategy configuration used by Remote Access routes and tunnel lifecycle controls. |
|
||||||
| `mcpServers` | `McpServersSettings` | `{ enabled: false, servers: [] }` | Global MCP server declarations shared across projects. Project `mcpServers` can enable/disable the effective set, override a same-named global server, or disable a global server with a same-named `enabled:false` entry. Sensitive env/header/token values must be `{ secretRef, scope }` references to Fusion-managed secrets, never plaintext. |
|
| `mcpServers` | `McpServersSettings` | `{ enabled: false, servers: [] }` | Global MCP server declarations shared across projects. Project `mcpServers` can enable/disable the effective set, override a same-named global server, or disable a global server with a same-named `enabled:false` entry. Sensitive env/header/token values must be `{ secretRef, scope }` references to Fusion-managed secrets, never plaintext. |
|
||||||
| `worktrunk` | `WorktrunkSettings` | `{ enabled: false, binaryPath: undefined, installedBinaryPath: undefined, onFailure: "fail" }` | Global defaults for worktrunk integration. Merged field-by-field with project `worktrunk` values; project values override global values for matching fields. |
|
| `worktrunk` | `WorktrunkSettings` | `{ enabled: false, binaryPath: undefined, installedBinaryPath: undefined, onFailure: "fail" }` | Global defaults for worktrunk integration. Merged field-by-field with project `worktrunk` values; project values override global values for matching fields. |
|
||||||
@@ -1567,6 +1567,7 @@ Common built-in dashboard/runtime flags include:
|
|||||||
- `todoView` (enables dashboard Todo View; see [Todo View](./todo-view.md))
|
- `todoView` (enables dashboard Todo View; see [Todo View](./todo-view.md))
|
||||||
- `researchView`
|
- `researchView`
|
||||||
- `evalsView` (gates Evals dashboard view, Settings → Scheduled Evals section, and scheduled-eval cron execution)
|
- `evalsView` (gates Evals dashboard view, Settings → Scheduled Evals section, and scheduled-eval cron execution)
|
||||||
|
- `ideationView` (default off; gates the top-level Ideation view, which is mobile More-only and replaces the Command Center Ideation tab)
|
||||||
- `workflowGraphExecutor` (enables the workflow-IR interpreter path)
|
- `workflowGraphExecutor` (enables the workflow-IR interpreter path)
|
||||||
- `graphNativePostMerge` (**default-ON**; the graph is the sole owner of post-merge `optional-group` steps after a successful merge — the legacy merger-owned post-merge path was deleted. Post-merge failures are non-blocking. See [Workflow Steps → Execution Phases](./workflow-steps.md#execution-phases))
|
- `graphNativePostMerge` (**default-ON**; the graph is the sole owner of post-merge `optional-group` steps after a successful merge — the legacy merger-owned post-merge path was deleted. Post-merge failures are non-blocking. See [Workflow Steps → Execution Phases](./workflow-steps.md#execution-phases))
|
||||||
- `workflowInterpreterDualObserve` (retired/inert; stale persisted `true` values are forced OFF and must not reactivate hidden shadow observation)
|
- `workflowInterpreterDualObserve` (retired/inert; stale persisted `true` values are forced OFF and must not reactivate hidden shadow observation)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { DEFAULT_MOBILE_NAV_PRIMARY_ITEMS, resolveMobileNavPrimaryItems } from "../mobile-nav-primary-items.js";
|
import { DEFAULT_MOBILE_NAV_PRIMARY_ITEMS, MOBILE_NAV_SELECTABLE_ITEMS, resolveMobileNavPrimaryItems } from "../mobile-nav-primary-items.js";
|
||||||
|
|
||||||
describe("resolveMobileNavPrimaryItems", () => {
|
describe("resolveMobileNavPrimaryItems", () => {
|
||||||
it("uses the existing six-tab order for unset or empty values", () => {
|
it("uses the existing six-tab order for unset or empty values", () => {
|
||||||
@@ -14,6 +14,14 @@ describe("resolveMobileNavPrimaryItems", () => {
|
|||||||
expect(resolved.omittedItems).toContain("settings");
|
expect(resolved.omittedItems).toContain("settings");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps Ideation in More when stale settings try to promote it", () => {
|
||||||
|
const resolved = resolveMobileNavPrimaryItems({ mobileNavPrimaryItems: ["ideation"] });
|
||||||
|
|
||||||
|
expect(MOBILE_NAV_SELECTABLE_ITEMS).toContain("ideation");
|
||||||
|
expect(resolved.primaryItems).not.toContain("ideation");
|
||||||
|
expect(resolved.omittedItems).toContain("ideation");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps newly eligible settings and documents, drops overflow-only ids, deduplicates, and clamps footer tabs", () => {
|
it("keeps newly eligible settings and documents, drops overflow-only ids, deduplicates, and clamps footer tabs", () => {
|
||||||
const resolved = resolveMobileNavPrimaryItems({
|
const resolved = resolveMobileNavPrimaryItems({
|
||||||
mobileNavPrimaryItems: ["settings", "tasks", "more", "documents", "tasks", "agents", "missions", "chat", "mailbox", "planning", "unknown"],
|
mobileNavPrimaryItems: ["settings", "tasks", "more", "documents", "tasks", "agents", "missions", "chat", "mailbox", "planning", "unknown"],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ProjectSettings } from "./types.js";
|
import type { ProjectSettings } from "./types.js";
|
||||||
|
|
||||||
/** The only destinations that may be promoted into the mobile footer. */
|
/** Destinations represented by the mobile navigation registry. */
|
||||||
export const MOBILE_NAV_SELECTABLE_ITEMS = [
|
export const MOBILE_NAV_SELECTABLE_ITEMS = [
|
||||||
"command-center",
|
"command-center",
|
||||||
"tasks",
|
"tasks",
|
||||||
@@ -25,6 +25,13 @@ export const MOBILE_NAV_SELECTABLE_ITEMS = [
|
|||||||
"memory",
|
"memory",
|
||||||
"research",
|
"research",
|
||||||
"evals",
|
"evals",
|
||||||
|
/*
|
||||||
|
FNXC:Navigation 2026-08-01-00:00:
|
||||||
|
FN-8352 makes Ideation a top-level experimental destination, but it remains
|
||||||
|
More-only on mobile. Keep it registered for labels and More rendering while
|
||||||
|
excluding it from the footer-promotion registry below.
|
||||||
|
*/
|
||||||
|
"ideation",
|
||||||
"goals",
|
"goals",
|
||||||
"todos",
|
"todos",
|
||||||
"dev-server",
|
"dev-server",
|
||||||
@@ -57,11 +64,17 @@ export const MOBILE_NAV_SELECTABLE_ITEM_LABEL_KEYS: Record<MobileNavSelectableIt
|
|||||||
memory: "nav.memory",
|
memory: "nav.memory",
|
||||||
research: "nav.research",
|
research: "nav.research",
|
||||||
evals: "nav.evals",
|
evals: "nav.evals",
|
||||||
|
ideation: "nav.ideation",
|
||||||
goals: "nav.goals",
|
goals: "nav.goals",
|
||||||
todos: "nav.todos",
|
todos: "nav.todos",
|
||||||
"dev-server": "nav.devServer",
|
"dev-server": "nav.devServer",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Destinations that Settings may promote into the mobile footer. */
|
||||||
|
export const MOBILE_NAV_PRIMARY_SELECTABLE_ITEMS = MOBILE_NAV_SELECTABLE_ITEMS.filter(
|
||||||
|
(item): item is Exclude<MobileNavSelectableItem, "ideation"> => item !== "ideation",
|
||||||
|
);
|
||||||
|
|
||||||
export const DEFAULT_MOBILE_NAV_PRIMARY_ITEMS: MobileNavSelectableItem[] = [
|
export const DEFAULT_MOBILE_NAV_PRIMARY_ITEMS: MobileNavSelectableItem[] = [
|
||||||
"command-center", "tasks", "agents", "missions", "chat", "mailbox",
|
"command-center", "tasks", "agents", "missions", "chat", "mailbox",
|
||||||
];
|
];
|
||||||
@@ -81,7 +94,7 @@ the render layer suppresses disabled experimental destinations, and More remains
|
|||||||
*/
|
*/
|
||||||
export function resolveMobileNavPrimaryItems(settings?: Pick<ProjectSettings, "mobileNavPrimaryItems">): ResolvedMobileNavPrimaryItems {
|
export function resolveMobileNavPrimaryItems(settings?: Pick<ProjectSettings, "mobileNavPrimaryItems">): ResolvedMobileNavPrimaryItems {
|
||||||
const selected = Array.isArray(settings?.mobileNavPrimaryItems) ? settings.mobileNavPrimaryItems : [];
|
const selected = Array.isArray(settings?.mobileNavPrimaryItems) ? settings.mobileNavPrimaryItems : [];
|
||||||
const valid = new Set<string>(MOBILE_NAV_SELECTABLE_ITEMS);
|
const valid = new Set<string>(MOBILE_NAV_PRIMARY_SELECTABLE_ITEMS);
|
||||||
const primaryItems = selected.reduce<MobileNavSelectableItem[]>((items, id) => {
|
const primaryItems = selected.reduce<MobileNavSelectableItem[]>((items, id) => {
|
||||||
if (valid.has(id) && !items.includes(id as MobileNavSelectableItem) && items.length < MAX_MOBILE_NAV_PRIMARY_ITEMS) {
|
if (valid.has(id) && !items.includes(id as MobileNavSelectableItem) && items.length < MAX_MOBILE_NAV_PRIMARY_ITEMS) {
|
||||||
items.push(id as MobileNavSelectableItem);
|
items.push(id as MobileNavSelectableItem);
|
||||||
|
|||||||
@@ -801,6 +801,7 @@ function AppInner() {
|
|||||||
const nodesEnabled = experimentalFeatures.nodesView === true;
|
const nodesEnabled = experimentalFeatures.nodesView === true;
|
||||||
const researchEnabled = experimentalFeatures.researchView === true;
|
const researchEnabled = experimentalFeatures.researchView === true;
|
||||||
const evalsEnabled = experimentalFeatures.evalsView === true;
|
const evalsEnabled = experimentalFeatures.evalsView === true;
|
||||||
|
const ideationEnabled = experimentalFeatures.ideationView === true;
|
||||||
/* FNXC:QuickAddSubtaskFlag 2026-06-21-00:00: Missing or false `subtaskBreakdown` settings must hide the AI Subtask quick-add handoff across List, Board, and New Task Modal surfaces; only an explicit true wires the callback. */
|
/* FNXC:QuickAddSubtaskFlag 2026-06-21-00:00: Missing or false `subtaskBreakdown` settings must hide the AI Subtask quick-add handoff across List, Board, and New Task Modal surfaces; only an explicit true wires the callback. */
|
||||||
const subtaskBreakdownEnabled = experimentalFeatures.subtaskBreakdown === true;
|
const subtaskBreakdownEnabled = experimentalFeatures.subtaskBreakdown === true;
|
||||||
/*
|
/*
|
||||||
@@ -863,13 +864,22 @@ function AppInner() {
|
|||||||
if (taskView === "evals" && !evalsEnabled) {
|
if (taskView === "evals" && !evalsEnabled) {
|
||||||
handleChangeTaskView("board");
|
handleChangeTaskView("board");
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
FNXC:Navigation 2026-08-01-00:00:
|
||||||
|
FN-8352 promotes Ideation to a default-off experimental top-level view.
|
||||||
|
Redirect persisted and deep-linked disabled views to Board so MainContent
|
||||||
|
never leaves users on a blank unavailable surface.
|
||||||
|
*/
|
||||||
|
if (taskView === "ideation" && !ideationEnabled) {
|
||||||
|
handleChangeTaskView("board");
|
||||||
|
}
|
||||||
if (taskView === "goalsView" && !goalsEnabled) {
|
if (taskView === "goalsView" && !goalsEnabled) {
|
||||||
handleChangeTaskView("board");
|
handleChangeTaskView("board");
|
||||||
}
|
}
|
||||||
if (taskView === "todos" && !todosEnabled) {
|
if (taskView === "todos" && !todosEnabled) {
|
||||||
handleChangeTaskView("board");
|
handleChangeTaskView("board");
|
||||||
}
|
}
|
||||||
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, researchEnabled, evalsEnabled, goalsEnabled, todosEnabled, graphPluginTaskView]);
|
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, researchEnabled, evalsEnabled, ideationEnabled, goalsEnabled, todosEnabled, graphPluginTaskView]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
availableModels,
|
availableModels,
|
||||||
@@ -1518,6 +1528,7 @@ function AppInner() {
|
|||||||
openSettingsWithNav,
|
openSettingsWithNav,
|
||||||
researchReadinessVersion,
|
researchReadinessVersion,
|
||||||
evalsEnabled,
|
evalsEnabled,
|
||||||
|
ideationEnabled,
|
||||||
memoryEnabled,
|
memoryEnabled,
|
||||||
goalsEnabled,
|
goalsEnabled,
|
||||||
handleOpenMission,
|
handleOpenMission,
|
||||||
@@ -1729,6 +1740,7 @@ function AppInner() {
|
|||||||
devServerView: devServerEnabled,
|
devServerView: devServerEnabled,
|
||||||
researchView: researchEnabled,
|
researchView: researchEnabled,
|
||||||
evalsView: evalsEnabled,
|
evalsView: evalsEnabled,
|
||||||
|
ideationView: ideationEnabled,
|
||||||
goalsView: goalsEnabled,
|
goalsView: goalsEnabled,
|
||||||
leftSidebarNav: leftSidebarNavEnabled,
|
leftSidebarNav: leftSidebarNavEnabled,
|
||||||
rightDock: rightDockEnabled,
|
rightDock: rightDockEnabled,
|
||||||
@@ -1763,6 +1775,7 @@ function AppInner() {
|
|||||||
devServerView: devServerEnabled,
|
devServerView: devServerEnabled,
|
||||||
researchView: researchEnabled,
|
researchView: researchEnabled,
|
||||||
evalsView: evalsEnabled,
|
evalsView: evalsEnabled,
|
||||||
|
ideationView: ideationEnabled,
|
||||||
goalsView: goalsEnabled,
|
goalsView: goalsEnabled,
|
||||||
}}
|
}}
|
||||||
pluginDashboardViews={pluginDashboardViews}
|
pluginDashboardViews={pluginDashboardViews}
|
||||||
@@ -1850,6 +1863,7 @@ function AppInner() {
|
|||||||
todoView: todosEnabled,
|
todoView: todosEnabled,
|
||||||
researchView: researchEnabled,
|
researchView: researchEnabled,
|
||||||
evalsView: evalsEnabled,
|
evalsView: evalsEnabled,
|
||||||
|
ideationView: ideationEnabled,
|
||||||
goalsView: goalsEnabled,
|
goalsView: goalsEnabled,
|
||||||
}}
|
}}
|
||||||
pluginDashboardViews={pluginDashboardViews}
|
pluginDashboardViews={pluginDashboardViews}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback, useMemo, type ReactNode } from "react";
|
import { useState, useEffect, useRef, useCallback, useMemo, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Settings, LayoutGrid, List, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Workflow, Bot, Target, Grid3X3, Mail, MessageSquare, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock, Gauge, ChevronDown, ChevronRight, PanelRight } from "lucide-react";
|
import { Settings, LayoutGrid, List, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Workflow, Bot, Target, Grid3X3, Mail, MessageSquare, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock, Gauge, Lightbulb, ChevronDown, ChevronRight, PanelRight } from "lucide-react";
|
||||||
import "./Header.css";
|
import "./Header.css";
|
||||||
// ProjectSelector styles used by the imported standalone component.
|
// ProjectSelector styles used by the imported standalone component.
|
||||||
import "./ProjectSelector.css";
|
import "./ProjectSelector.css";
|
||||||
@@ -132,7 +132,7 @@ export interface HeaderProps {
|
|||||||
/** Whether the current view is a remote node */
|
/** Whether the current view is a remote node */
|
||||||
isRemote?: boolean;
|
isRemote?: boolean;
|
||||||
/** Experimental feature flags controlling visibility of nav items. */
|
/** Experimental feature flags controlling visibility of nav items. */
|
||||||
experimentalFeatures?: { insights?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean; evalsView?: boolean; goalsView?: boolean; leftSidebarNav?: boolean; rightDock?: boolean };
|
experimentalFeatures?: { insights?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean; evalsView?: boolean; ideationView?: boolean; goalsView?: boolean; leftSidebarNav?: boolean; rightDock?: boolean };
|
||||||
pluginDashboardViews?: PluginDashboardViewEntry[];
|
pluginDashboardViews?: PluginDashboardViewEntry[];
|
||||||
shellConnectionControl?: ReactNode;
|
shellConnectionControl?: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -236,6 +236,7 @@ export function Header({
|
|||||||
return !!(
|
return !!(
|
||||||
onChangeView ||
|
onChangeView ||
|
||||||
experimentalFeatures?.researchView ||
|
experimentalFeatures?.researchView ||
|
||||||
|
experimentalFeatures?.ideationView ||
|
||||||
todosEnabled ||
|
todosEnabled ||
|
||||||
experimentalFeatures?.insights ||
|
experimentalFeatures?.insights ||
|
||||||
|
|
||||||
@@ -784,7 +785,7 @@ export function Header({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
ref={viewOverflowTriggerRef}
|
ref={viewOverflowTriggerRef}
|
||||||
className={`view-toggle-btn${(["research", "skills", "insights", "memory", "secrets", "dev-server", "devserver", "graph", "todos"].includes(view) || (isTablet && view === "documents") || (experimentalFeatures?.evalsView && view === "evals") || (experimentalFeatures?.goalsView && view === "goalsView") || isPluginViewId(view)) ? " active" : ""}`}
|
className={`view-toggle-btn${(["research", "ideation", "skills", "insights", "memory", "secrets", "dev-server", "devserver", "graph", "todos"].includes(view) || (isTablet && view === "documents") || (experimentalFeatures?.evalsView && view === "evals") || (experimentalFeatures?.goalsView && view === "goalsView") || isPluginViewId(view)) ? " active" : ""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsViewOverflowOpen((prev) => !prev);
|
setIsViewOverflowOpen((prev) => !prev);
|
||||||
}}
|
}}
|
||||||
@@ -845,6 +846,20 @@ export function Header({
|
|||||||
<span>{t("header.researchView", "Research")}</span>
|
<span>{t("header.researchView", "Research")}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{experimentalFeatures?.ideationView && (
|
||||||
|
<button
|
||||||
|
className={`view-toggle-overflow-item${view === "ideation" ? " active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
onChangeView("ideation");
|
||||||
|
setIsViewOverflowOpen(false);
|
||||||
|
}}
|
||||||
|
role="menuitem"
|
||||||
|
data-testid="view-overflow-ideation"
|
||||||
|
>
|
||||||
|
<Lightbulb size={14} />
|
||||||
|
<span>{t("nav.ideation", "Ideation")}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{experimentalFeatures?.insights && (
|
{experimentalFeatures?.insights && (
|
||||||
<button
|
<button
|
||||||
className={`view-toggle-overflow-item${view === "insights" ? " active" : ""}`}
|
className={`view-toggle-overflow-item${view === "insights" ? " active" : ""}`}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export interface LeftSidebarExperimentalFeatures {
|
|||||||
devServerView?: boolean;
|
devServerView?: boolean;
|
||||||
researchView?: boolean;
|
researchView?: boolean;
|
||||||
evalsView?: boolean;
|
evalsView?: boolean;
|
||||||
|
ideationView?: boolean;
|
||||||
goalsView?: boolean;
|
goalsView?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,11 +280,11 @@ export function LeftSidebarNav({
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:Navigation 2026-06-22-12:00:
|
FNXC:Navigation 2026-06-22-12:00:
|
||||||
Single explicit sidebar order (top to bottom): board, list, graph, agents, chat, mailbox, planning, missions, goals, compound, automation, import, workflows, insight, research, command-center, documents (Artifacts), skills, memory, evals, then any remaining plugin views in their sorted order.
|
Single explicit sidebar order (top to bottom): board, list, graph, agents, chat, mailbox, planning, missions, goals, compound, automation, import, workflows, insight, research, ideation, command-center, documents (Artifacts), skills, memory, evals, then any remaining plugin views in their sorted order.
|
||||||
|
|
||||||
Dev Server is intentionally absent: it moved to the right dock. Secrets and Todos remain omitted (they live in the right dock / mobile More-sheet / Header overflow).
|
Dev Server is intentionally absent: it moved to the right dock. Secrets and Todos remain omitted (they live in the right dock / mobile More-sheet / Header overflow).
|
||||||
|
|
||||||
Flag gates preserved verbatim from the prior layout: agents (showAgentsTab), goals (goalsView), insight (insights), research (researchView), skills (showSkillsTab), memory (memoryView), evals (evalsView). graph and compound are skipped when their plugin view is absent.
|
Flag gates preserved verbatim from the prior layout: agents (showAgentsTab), goals (goalsView), insight (insights), research (researchView), ideation (ideationView), skills (showSkillsTab), memory (memoryView), evals (evalsView). graph and compound are skipped when their plugin view is absent.
|
||||||
|
|
||||||
FNXC:Navigation 2026-06-22-18:50:
|
FNXC:Navigation 2026-06-22-18:50:
|
||||||
Roadmaps is no longer a dashboard navigation destination. Keep filtering it out even if a persisted plugin dashboard-view row is present, while preserving other plugin views in their sorted fallback section.
|
Roadmaps is no longer a dashboard navigation destination. Keep filtering it out even if a persisted plugin dashboard-view row is present, while preserving other plugin views in their sorted fallback section.
|
||||||
@@ -443,6 +444,9 @@ export function LeftSidebarNav({
|
|||||||
...(experimentalFeatures?.researchView
|
...(experimentalFeatures?.researchView
|
||||||
? [{ id: "research", label: t("header.researchView", "Research"), view: "research" as TaskView, isActive: view === "research", icon: Search, testId: "sidebar-nav-research", onSelect: () => onChangeView("research") }]
|
? [{ id: "research", label: t("header.researchView", "Research"), view: "research" as TaskView, isActive: view === "research", icon: Search, testId: "sidebar-nav-research", onSelect: () => onChangeView("research") }]
|
||||||
: []),
|
: []),
|
||||||
|
...(experimentalFeatures?.ideationView
|
||||||
|
? [{ id: "ideation", label: t("nav.ideation", "Ideation"), view: "ideation" as TaskView, isActive: view === "ideation", icon: Lightbulb, testId: "sidebar-nav-ideation", onSelect: () => onChangeView("ideation") }]
|
||||||
|
: []),
|
||||||
...(experimentalFeatures?.evalsView
|
...(experimentalFeatures?.evalsView
|
||||||
? [{ id: "evals", label: t("header.evalsView", "Evals"), view: "evals" as TaskView, isActive: view === "evals", icon: Target, testId: "sidebar-nav-evals", onSelect: () => onChangeView("evals") }]
|
? [{ id: "evals", label: t("header.evalsView", "Evals"), view: "evals" as TaskView, isActive: view === "evals", icon: Target, testId: "sidebar-nav-evals", onSelect: () => onChangeView("evals") }]
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export interface MobileNavBarProps {
|
|||||||
todoView?: boolean;
|
todoView?: boolean;
|
||||||
researchView?: boolean;
|
researchView?: boolean;
|
||||||
evalsView?: boolean;
|
evalsView?: boolean;
|
||||||
|
ideationView?: boolean;
|
||||||
goalsView?: boolean;
|
goalsView?: boolean;
|
||||||
};
|
};
|
||||||
pluginDashboardViews?: PluginDashboardViewEntry[];
|
pluginDashboardViews?: PluginDashboardViewEntry[];
|
||||||
@@ -440,6 +441,7 @@ export function MobileNavBar({
|
|||||||
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")) },
|
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")) },
|
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")) },
|
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")) },
|
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")) },
|
||||||
todos: { icon: <CheckSquare />, labelKey: "nav.todos", fallback: "Todos", moreTestId: "mobile-more-item-todos", isActive: view === "todos", isAvailable: todoViewEnabled, navigate: (surface) => surface === "primary" ? onChangeView("todos") : handleMoreAction(() => onChangeView("todos")) },
|
todos: { icon: <CheckSquare />, labelKey: "nav.todos", fallback: "Todos", moreTestId: "mobile-more-item-todos", isActive: view === "todos", isAvailable: todoViewEnabled, navigate: (surface) => surface === "primary" ? onChangeView("todos") : handleMoreAction(() => onChangeView("todos")) },
|
||||||
"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")) },
|
"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")) },
|
||||||
|
|||||||
@@ -682,6 +682,12 @@ const KNOWN_EXPERIMENTAL_FEATURES: Record<string, string> = {
|
|||||||
todoView: "Todo List",
|
todoView: "Todo List",
|
||||||
researchView: "Research View",
|
researchView: "Research View",
|
||||||
evalsView: "Evals View",
|
evalsView: "Evals View",
|
||||||
|
/*
|
||||||
|
FNXC:SettingsExperimental 2026-08-01-00:00:
|
||||||
|
FN-8352 promotes Ideation to a default-off top-level view. Keep its toggle
|
||||||
|
visible so operators explicitly opt into the sidebar and mobile More surface.
|
||||||
|
*/
|
||||||
|
ideationView: "Ideation View",
|
||||||
qualityPlugin: "Quality Plugin",
|
qualityPlugin: "Quality Plugin",
|
||||||
goalsView: "Goals View",
|
goalsView: "Goals View",
|
||||||
/* FNXC:QuickAddSubtaskFlag 2026-06-21-00:00: The AI subtask-breakdown quick-add affordance is exposed only through this default-off experimental flag so missing settings keep every quick-add Subtask button hidden. */
|
/* FNXC:QuickAddSubtaskFlag 2026-06-21-00:00: The AI subtask-breakdown quick-add affordance is exposed only through this default-off experimental flag so missing settings keep every quick-add Subtask button hidden. */
|
||||||
|
|||||||
@@ -2349,6 +2349,26 @@ describe("App view switching", () => {
|
|||||||
localStorage.removeItem(taskViewStorageKey());
|
localStorage.removeItem(taskViewStorageKey());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to Board when persisted Ideation view is feature-disabled", async () => {
|
||||||
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||||
|
localStorage.setItem(taskViewStorageKey(), "ideation");
|
||||||
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
...defaultSettings,
|
||||||
|
experimentalFeatures: {
|
||||||
|
...defaultSettings.experimentalFeatures,
|
||||||
|
ideationView: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(document.querySelector(".board")).toBeTruthy());
|
||||||
|
expect(screen.queryByLabelText("Persisted ideation")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
localStorage.removeItem("kb-dashboard-view-mode");
|
||||||
|
localStorage.removeItem(taskViewStorageKey());
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to board when evals view is feature-disabled", async () => {
|
it("falls back to board when evals view is feature-disabled", async () => {
|
||||||
localStorage.setItem("kb-dashboard-view-mode", "project");
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||||
localStorage.setItem(taskViewStorageKey(), "evals");
|
localStorage.setItem(taskViewStorageKey(), "evals");
|
||||||
|
|||||||
@@ -430,6 +430,21 @@ describe("Header", () => {
|
|||||||
expect(screen.queryByTestId("view-overflow-research")).toBeNull();
|
expect(screen.queryByTestId("view-overflow-research")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gates Ideation in the desktop overflow and routes the enabled fallback", () => {
|
||||||
|
const hidden = renderHeader({ onChangeView: noop, experimentalFeatures: { ideationView: false } });
|
||||||
|
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
||||||
|
expect(screen.queryByTestId("view-overflow-ideation")).toBeNull();
|
||||||
|
hidden.unmount();
|
||||||
|
|
||||||
|
const onChangeView = vi.fn();
|
||||||
|
renderHeader({ onChangeView, view: "ideation", experimentalFeatures: { ideationView: true } });
|
||||||
|
const trigger = screen.getByTestId("view-toggle-overflow-trigger");
|
||||||
|
expect(trigger).toHaveClass("active");
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
fireEvent.click(screen.getByTestId("view-overflow-ideation"));
|
||||||
|
expect(onChangeView).toHaveBeenCalledWith("ideation");
|
||||||
|
});
|
||||||
|
|
||||||
it("hides evals in the desktop view overflow when evalsView is disabled", () => {
|
it("hides evals in the desktop view overflow when evalsView is disabled", () => {
|
||||||
renderHeader({ onChangeView: noop, experimentalFeatures: { evalsView: false } });
|
renderHeader({ onChangeView: noop, experimentalFeatures: { evalsView: false } });
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ function renderSidebar(overrides: Partial<ComponentProps<typeof LeftSidebarNav>>
|
|||||||
devServerView: true,
|
devServerView: true,
|
||||||
researchView: true,
|
researchView: true,
|
||||||
evalsView: true,
|
evalsView: true,
|
||||||
|
ideationView: true,
|
||||||
goalsView: true,
|
goalsView: true,
|
||||||
},
|
},
|
||||||
pluginDashboardViews: pluginViews,
|
pluginDashboardViews: pluginViews,
|
||||||
@@ -224,6 +225,7 @@ describe("LeftSidebarNav", () => {
|
|||||||
"sidebar-nav-workflows",
|
"sidebar-nav-workflows",
|
||||||
"sidebar-nav-insights",
|
"sidebar-nav-insights",
|
||||||
"sidebar-nav-research",
|
"sidebar-nav-research",
|
||||||
|
"sidebar-nav-ideation",
|
||||||
"sidebar-nav-skills",
|
"sidebar-nav-skills",
|
||||||
"sidebar-nav-memory",
|
"sidebar-nav-memory",
|
||||||
"sidebar-nav-evals",
|
"sidebar-nav-evals",
|
||||||
@@ -287,6 +289,7 @@ describe("LeftSidebarNav", () => {
|
|||||||
"sidebar-nav-workflows",
|
"sidebar-nav-workflows",
|
||||||
"sidebar-nav-insights",
|
"sidebar-nav-insights",
|
||||||
"sidebar-nav-research",
|
"sidebar-nav-research",
|
||||||
|
"sidebar-nav-ideation",
|
||||||
"sidebar-nav-evals",
|
"sidebar-nav-evals",
|
||||||
];
|
];
|
||||||
const orderedIndices = orderedTestIds.map((testId) => primaryButtons.indexOf(screen.getByTestId(testId)));
|
const orderedIndices = orderedTestIds.map((testId) => primaryButtons.indexOf(screen.getByTestId(testId)));
|
||||||
@@ -352,6 +355,7 @@ describe("LeftSidebarNav", () => {
|
|||||||
expect(screen.queryByTestId("sidebar-nav-stash-recovery")).toBeNull();
|
expect(screen.queryByTestId("sidebar-nav-stash-recovery")).toBeNull();
|
||||||
expect(screen.queryByTestId("sidebar-nav-agents")).toBeNull();
|
expect(screen.queryByTestId("sidebar-nav-agents")).toBeNull();
|
||||||
expect(screen.queryByTestId("sidebar-nav-research")).toBeNull();
|
expect(screen.queryByTestId("sidebar-nav-research")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("sidebar-nav-ideation")).toBeNull();
|
||||||
expect(screen.queryByTestId("sidebar-nav-insights")).toBeNull();
|
expect(screen.queryByTestId("sidebar-nav-insights")).toBeNull();
|
||||||
expect(screen.queryByTestId("sidebar-nav-skills")).toBeNull();
|
expect(screen.queryByTestId("sidebar-nav-skills")).toBeNull();
|
||||||
expect(screen.queryByTestId("sidebar-nav-memory")).toBeNull();
|
expect(screen.queryByTestId("sidebar-nav-memory")).toBeNull();
|
||||||
@@ -458,6 +462,7 @@ describe("LeftSidebarNav", () => {
|
|||||||
it.each<[TaskView, string]>([
|
it.each<[TaskView, string]>([
|
||||||
["board", "sidebar-nav-board"],
|
["board", "sidebar-nav-board"],
|
||||||
["research", "sidebar-nav-research"],
|
["research", "sidebar-nav-research"],
|
||||||
|
["ideation", "sidebar-nav-ideation"],
|
||||||
["planning", "sidebar-nav-planning"],
|
["planning", "sidebar-nav-planning"],
|
||||||
["plugin:fusion-plugin-primary:primary-view", "sidebar-nav-plugin-fusion-plugin-primary-primary-view"],
|
["plugin:fusion-plugin-primary:primary-view", "sidebar-nav-plugin-fusion-plugin-primary-primary-view"],
|
||||||
["plugin:fusion-plugin-overflow:overflow-view", "sidebar-nav-plugin-fusion-plugin-overflow-overflow-view"],
|
["plugin:fusion-plugin-overflow:overflow-view", "sidebar-nav-plugin-fusion-plugin-overflow-overflow-view"],
|
||||||
|
|||||||
@@ -170,16 +170,30 @@ describe("MobileNavBar", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders every available selectable destination exactly once across tab and More", () => {
|
it("renders every available selectable destination exactly once across tab and More", () => {
|
||||||
const gated = new Set(["skills", "insights", "memory", "research", "evals", "goals", "todos", "dev-server"]);
|
const gated = new Set(["skills", "insights", "memory", "research", "evals", "ideation", "goals", "todos", "dev-server"]);
|
||||||
const moreTestIds: Record<string, string> = { automation: "schedules", "github-import": "github", workflows: "workflow" };
|
const moreTestIds: Record<string, string> = { automation: "schedules", "github-import": "github", workflows: "workflow" };
|
||||||
for (const item of MOBILE_NAV_SELECTABLE_ITEMS) {
|
for (const item of MOBILE_NAV_SELECTABLE_ITEMS) {
|
||||||
const { unmount } = render(<MobileNavBar {...createDefaultProps()} mobileNavPrimaryItems={[item]} showSkillsTab experimentalFeatures={{ insights: true, memoryView: true, researchView: true, evalsView: true, goalsView: true, todoView: true, devServerView: true }} />);
|
const { unmount } = render(<MobileNavBar {...createDefaultProps()} mobileNavPrimaryItems={[item]} showSkillsTab experimentalFeatures={{ insights: true, memoryView: true, researchView: true, evalsView: true, ideationView: true, goalsView: true, todoView: true, devServerView: true }} />);
|
||||||
expect(screen.getAllByTestId(`mobile-nav-tab-${item}`)).toHaveLength(1);
|
if (item === "ideation") {
|
||||||
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
expect(screen.queryByTestId("mobile-nav-tab-ideation")).toBeNull();
|
||||||
expect(screen.queryByTestId(`mobile-more-item-${moreTestIds[item] ?? item}`)).toBeNull();
|
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||||
|
expect(screen.getAllByTestId("mobile-more-item-ideation")).toHaveLength(1);
|
||||||
|
} else {
|
||||||
|
expect(screen.getAllByTestId(`mobile-nav-tab-${item}`)).toHaveLength(1);
|
||||||
|
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||||
|
expect(screen.queryByTestId(`mobile-more-item-${moreTestIds[item] ?? item}`)).toBeNull();
|
||||||
|
}
|
||||||
unmount();
|
unmount();
|
||||||
}
|
}
|
||||||
expect(gated.size).toBe(8);
|
expect(gated.size).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps enabled Ideation in More when persisted customization lists it", () => {
|
||||||
|
render(<MobileNavBar {...createDefaultProps()} mobileNavPrimaryItems={["ideation"]} experimentalFeatures={{ ideationView: true }} />);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("mobile-nav-tab-ideation")).toBeNull();
|
||||||
|
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||||
|
expect(screen.getAllByTestId("mobile-more-item-ideation")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not render legacy roadmaps tab", () => {
|
it("does not render legacy roadmaps tab", () => {
|
||||||
@@ -799,6 +813,23 @@ describe("MobileNavBar", () => {
|
|||||||
expect(screen.getByTestId("mobile-more-item-research")).toBeDefined();
|
expect(screen.getByTestId("mobile-more-item-research")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows Ideation in More only when ideationView is enabled and routes to it", () => {
|
||||||
|
const props = createDefaultProps();
|
||||||
|
const { container } = render(<MobileNavBar {...props} experimentalFeatures={{ ideationView: true }} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||||
|
fireEvent.click(screen.getByTestId("mobile-more-item-ideation"));
|
||||||
|
|
||||||
|
expect(container.querySelector(".mobile-more-sheet")).toBeNull();
|
||||||
|
expect(props.onChangeView).toHaveBeenCalledWith("ideation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show Ideation in more sheet when ideationView is disabled", () => {
|
||||||
|
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{ ideationView: false }} />);
|
||||||
|
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||||
|
expect(screen.queryByTestId("mobile-more-item-ideation")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not show research in more sheet when experimentalFeatures.researchView is false", () => {
|
it("does not show research in more sheet when experimentalFeatures.researchView is false", () => {
|
||||||
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{ researchView: false }} />);
|
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{ researchView: false }} />);
|
||||||
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { SystemStatsArea } from "./areas/SystemStatsArea";
|
|||||||
import { SystemControlsArea } from "./areas/SystemControlsArea";
|
import { SystemControlsArea } from "./areas/SystemControlsArea";
|
||||||
import { PluginManager } from "../PluginManager";
|
import { PluginManager } from "../PluginManager";
|
||||||
import { MissionControlPanel } from "./MissionControlPanel";
|
import { MissionControlPanel } from "./MissionControlPanel";
|
||||||
import { IdeationPanel } from "./IdeationPanel";
|
|
||||||
import { CommandCenterControls } from "./CommandCenterControls";
|
import { CommandCenterControls } from "./CommandCenterControls";
|
||||||
import { ReliabilityView } from "../ReliabilityView";
|
import { ReliabilityView } from "../ReliabilityView";
|
||||||
import { NodesView } from "../NodesView";
|
import { NodesView } from "../NodesView";
|
||||||
@@ -54,8 +53,7 @@ type SubViewId =
|
|||||||
| "plugins"
|
| "plugins"
|
||||||
| "nodes"
|
| "nodes"
|
||||||
| "reliability"
|
| "reliability"
|
||||||
| "mission-control"
|
| "mission-control";
|
||||||
| "ideation";
|
|
||||||
|
|
||||||
interface SubView {
|
interface SubView {
|
||||||
id: SubViewId;
|
id: SubViewId;
|
||||||
@@ -100,8 +98,12 @@ function useSubViews(nodesEnabled: boolean): SubView[] {
|
|||||||
{ id: "plugins", label: t("commandCenter.tabs.plugins", "Plugins") },
|
{ id: "plugins", label: t("commandCenter.tabs.plugins", "Plugins") },
|
||||||
...(nodesEnabled ? [{ id: "nodes" as const, label: t("commandCenter.tabs.nodes", "Nodes") }] : []),
|
...(nodesEnabled ? [{ id: "nodes" as const, label: t("commandCenter.tabs.nodes", "Nodes") }] : []),
|
||||||
{ id: "reliability", label: t("commandCenter.tabs.reliability", "Reliability") },
|
{ id: "reliability", label: t("commandCenter.tabs.reliability", "Reliability") },
|
||||||
|
/*
|
||||||
|
FNXC:Navigation 2026-08-01-00:00:
|
||||||
|
FN-8352 removes Ideation from Command Center because its experimental
|
||||||
|
top-level navigation view is now the single canonical host.
|
||||||
|
*/
|
||||||
{ id: "mission-control", label: t("commandCenter.tabs.missionControl", "Mission Control") },
|
{ id: "mission-control", label: t("commandCenter.tabs.missionControl", "Mission Control") },
|
||||||
{ id: "ideation", label: t("commandCenter.tabs.ideation", "Ideation") },
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,8 +709,6 @@ export function CommandCenter({
|
|||||||
return <ReliabilityView projectId={projectId} />;
|
return <ReliabilityView projectId={projectId} />;
|
||||||
case "mission-control":
|
case "mission-control":
|
||||||
return <MissionControlPanel projectId={projectId} />;
|
return <MissionControlPanel projectId={projectId} />;
|
||||||
case "ideation":
|
|
||||||
return <IdeationPanel projectId={projectId} />;
|
|
||||||
default:
|
default:
|
||||||
return <PlaceholderTab tabId={activeTab} />;
|
return <PlaceholderTab tabId={activeTab} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1039,8 +1039,9 @@ describe("CommandCenter shell", () => {
|
|||||||
render(<CommandCenter />);
|
render(<CommandCenter />);
|
||||||
const tablist = screen.getByRole("tablist");
|
const tablist = screen.getByRole("tablist");
|
||||||
const tabs = within(tablist).getAllByRole("tab");
|
const tabs = within(tablist).getAllByRole("tab");
|
||||||
// Overview, Tokens, Tools, Activity, Productivity, Team, Workflows, Ecosystem, GitHub, GitLab, Signals, System, Plugins, Reliability, Mission Control, Ideation.
|
// Overview, Tokens, Tools, Activity, Productivity, Team, Workflows, Ecosystem, GitHub, GitLab, Signals, System, Plugins, Reliability, Mission Control.
|
||||||
expect(tabs.length).toBe(17);
|
expect(tabs.length).toBe(16);
|
||||||
|
expect(screen.queryByTestId("command-center-tab-ideation")).toBeNull();
|
||||||
expect(screen.queryByTestId("command-center-tab-nodes")).toBeNull();
|
expect(screen.queryByTestId("command-center-tab-nodes")).toBeNull();
|
||||||
// roving tabindex: exactly one tab is focusable.
|
// roving tabindex: exactly one tab is focusable.
|
||||||
const focusable = tabs.filter((tab) => tab.getAttribute("tabindex") === "0");
|
const focusable = tabs.filter((tab) => tab.getAttribute("tabindex") === "0");
|
||||||
@@ -1294,7 +1295,7 @@ describe("CommandCenter shell", () => {
|
|||||||
const overviewTab = screen.getByTestId("command-center-tab-overview");
|
const overviewTab = screen.getByTestId("command-center-tab-overview");
|
||||||
overviewTab.focus();
|
overviewTab.focus();
|
||||||
fireEvent.keyDown(overviewTab, { key: "ArrowLeft" });
|
fireEvent.keyDown(overviewTab, { key: "ArrowLeft" });
|
||||||
const last = screen.getByTestId("command-center-tab-ideation");
|
const last = screen.getByTestId("command-center-tab-mission-control");
|
||||||
expect(last.getAttribute("aria-selected")).toBe("true");
|
expect(last.getAttribute("aria-selected")).toBe("true");
|
||||||
expect(document.activeElement).toBe(last);
|
expect(document.activeElement).toBe(last);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { IdeationPanel } from "../IdeationPanel";
|
||||||
|
|
||||||
|
function jsonResponse(value: unknown): Response {
|
||||||
|
return { ok: true, json: async () => value } as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("IdeationPanel", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the empty session state", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse([])));
|
||||||
|
|
||||||
|
render(<IdeationPanel projectId="project-1" />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("No sessions yet.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders populated and converged session details", async () => {
|
||||||
|
const session = {
|
||||||
|
id: "idea-1",
|
||||||
|
title: "Improve navigation",
|
||||||
|
status: "converged",
|
||||||
|
targetMissionId: "M-001",
|
||||||
|
candidates: [{ id: "candidate-1", content: "Use a More destination", origin: "human" }],
|
||||||
|
};
|
||||||
|
vi.stubGlobal("fetch", vi.fn()
|
||||||
|
.mockResolvedValueOnce(jsonResponse([{ id: session.id }]))
|
||||||
|
.mockResolvedValueOnce(jsonResponse(session)));
|
||||||
|
|
||||||
|
render(<IdeationPanel projectId="project-1" />);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", { name: session.title })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Converged to Mission")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("M-001")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Use a More destination")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes request errors as an alert", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, text: async () => "Ideation unavailable" }));
|
||||||
|
|
||||||
|
render(<IdeationPanel projectId="project-1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("Ideation unavailable"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import { TaskDetailContent } from "../TaskDetailModal";
|
|||||||
import { ProjectOverview } from "../ProjectOverview";
|
import { ProjectOverview } from "../ProjectOverview";
|
||||||
import { MissionManager } from "../MissionManager";
|
import { MissionManager } from "../MissionManager";
|
||||||
import { MailboxView } from "../MailboxView";
|
import { MailboxView } from "../MailboxView";
|
||||||
|
import { IdeationPanel } from "../command-center/IdeationPanel";
|
||||||
import { PageErrorBoundary } from "../ErrorBoundary";
|
import { PageErrorBoundary } from "../ErrorBoundary";
|
||||||
import { BackendConnectionErrorPage } from "../BackendConnectionErrorPage";
|
import { BackendConnectionErrorPage } from "../BackendConnectionErrorPage";
|
||||||
import { CapacityRiskBanner } from "../CapacityRiskBanner";
|
import { CapacityRiskBanner } from "../CapacityRiskBanner";
|
||||||
@@ -103,6 +104,7 @@ export function MainContent({
|
|||||||
openSettingsWithNav,
|
openSettingsWithNav,
|
||||||
researchReadinessVersion,
|
researchReadinessVersion,
|
||||||
evalsEnabled,
|
evalsEnabled,
|
||||||
|
ideationEnabled,
|
||||||
memoryEnabled,
|
memoryEnabled,
|
||||||
goalsEnabled,
|
goalsEnabled,
|
||||||
handleOpenMission,
|
handleOpenMission,
|
||||||
@@ -526,6 +528,17 @@ export function MainContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (taskView === "ideation") {
|
||||||
|
if (!settingsLoaded || !ideationEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<PageErrorBoundary>
|
||||||
|
<IdeationPanel projectId={currentProject?.id} />
|
||||||
|
</PageErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (taskView === "memory") {
|
if (taskView === "memory") {
|
||||||
if (!settingsLoaded || !memoryEnabled) {
|
if (!settingsLoaded || !memoryEnabled) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ export interface MainContentProps {
|
|||||||
openSettingsWithNav: (section?: SectionId) => void;
|
openSettingsWithNav: (section?: SectionId) => void;
|
||||||
researchReadinessVersion: number;
|
researchReadinessVersion: number;
|
||||||
evalsEnabled: boolean;
|
evalsEnabled: boolean;
|
||||||
|
ideationEnabled: boolean;
|
||||||
memoryEnabled: boolean;
|
memoryEnabled: boolean;
|
||||||
goalsEnabled: boolean;
|
goalsEnabled: boolean;
|
||||||
handleOpenMission: (missionId: string) => void;
|
handleOpenMission: (missionId: string) => void;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { DEPRECATED_BUILTIN_WORKFLOW_IDS, isLocale, SUPPORTED_LOCALES, type ReportActionType, type WorkflowDefinition } from "@fusion/core";
|
import { DEPRECATED_BUILTIN_WORKFLOW_IDS, isLocale, SUPPORTED_LOCALES, type ReportActionType, type WorkflowDefinition } from "@fusion/core";
|
||||||
import { DEFAULT_MOBILE_NAV_PRIMARY_ITEMS, MAX_MOBILE_NAV_PRIMARY_ITEMS, MOBILE_NAV_SELECTABLE_ITEMS, MOBILE_NAV_SELECTABLE_ITEM_LABEL_KEYS } from "../../../../../core/src/mobile-nav-primary-items";
|
import { DEFAULT_MOBILE_NAV_PRIMARY_ITEMS, MAX_MOBILE_NAV_PRIMARY_ITEMS, MOBILE_NAV_PRIMARY_SELECTABLE_ITEMS, MOBILE_NAV_SELECTABLE_ITEM_LABEL_KEYS } from "../../../../../core/src/mobile-nav-primary-items";
|
||||||
import { SettingsFieldRow } from "../SettingsFieldRow";
|
import { SettingsFieldRow } from "../SettingsFieldRow";
|
||||||
import { SettingsToggleRow } from "../SettingsToggleRow";
|
import { SettingsToggleRow } from "../SettingsToggleRow";
|
||||||
import { SettingsSelectRow } from "../SettingsSelectRow";
|
import { SettingsSelectRow } from "../SettingsSelectRow";
|
||||||
@@ -374,8 +374,8 @@ export function GeneralSection({ form, setForm, projectId, addToast, prefixError
|
|||||||
{/*
|
{/*
|
||||||
FNXC:Navigation 2026-07-17-00:00:
|
FNXC:Navigation 2026-07-17-00:00:
|
||||||
Render the selected quick actions in persisted order so move controls visibly reorder their rows.
|
Render the selected quick actions in persisted order so move controls visibly reorder their rows.
|
||||||
The add picker exposes every eligible destination, while each mutation updates the live footer before
|
The add picker exposes only footer-eligible destinations, while each mutation updates the live footer before
|
||||||
Settings is saved; More, Terminal/scripts, shell controls, and plugin views remain unavailable here.
|
Settings is saved; More, Ideation, Terminal/scripts, shell controls, and plugin views remain unavailable here.
|
||||||
*/}
|
*/}
|
||||||
<SettingsFieldRow
|
<SettingsFieldRow
|
||||||
htmlFor="mobileNavPrimaryItems"
|
htmlFor="mobileNavPrimaryItems"
|
||||||
@@ -386,13 +386,13 @@ export function GeneralSection({ form, setForm, projectId, addToast, prefixError
|
|||||||
<div role="group" aria-label={t("settings.general.mobileNavPrimaryItems", "Mobile footer quick actions")}>
|
<div role="group" aria-label={t("settings.general.mobileNavPrimaryItems", "Mobile footer quick actions")}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const selectedItems = Array.isArray(form.mobileNavPrimaryItems) && form.mobileNavPrimaryItems.length > 0
|
const selectedItems = Array.isArray(form.mobileNavPrimaryItems) && form.mobileNavPrimaryItems.length > 0
|
||||||
? form.mobileNavPrimaryItems.filter((item): item is typeof MOBILE_NAV_SELECTABLE_ITEMS[number] => MOBILE_NAV_SELECTABLE_ITEMS.includes(item as typeof MOBILE_NAV_SELECTABLE_ITEMS[number]))
|
? form.mobileNavPrimaryItems.filter((item): item is typeof MOBILE_NAV_PRIMARY_SELECTABLE_ITEMS[number] => MOBILE_NAV_PRIMARY_SELECTABLE_ITEMS.includes(item as typeof MOBILE_NAV_PRIMARY_SELECTABLE_ITEMS[number]))
|
||||||
: [...DEFAULT_MOBILE_NAV_PRIMARY_ITEMS];
|
: [...DEFAULT_MOBILE_NAV_PRIMARY_ITEMS];
|
||||||
const updateItems = (nextItems: string[]) => {
|
const updateItems = (nextItems: string[]) => {
|
||||||
setForm((current) => ({ ...current, mobileNavPrimaryItems: nextItems }));
|
setForm((current) => ({ ...current, mobileNavPrimaryItems: nextItems }));
|
||||||
onMobileNavPrimaryItemsChange?.(nextItems);
|
onMobileNavPrimaryItemsChange?.(nextItems);
|
||||||
};
|
};
|
||||||
const availableItems = MOBILE_NAV_SELECTABLE_ITEMS.filter((item) => !selectedItems.includes(item));
|
const availableItems = MOBILE_NAV_PRIMARY_SELECTABLE_ITEMS.filter((item) => !selectedItems.includes(item));
|
||||||
return <>
|
return <>
|
||||||
{selectedItems.map((item, selectedIndex) => {
|
{selectedItems.map((item, selectedIndex) => {
|
||||||
const label = t(MOBILE_NAV_SELECTABLE_ITEM_LABEL_KEYS[item], item);
|
const label = t(MOBILE_NAV_SELECTABLE_ITEM_LABEL_KEYS[item], item);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export type ViewMode = "overview" | "project";
|
|||||||
FNXC:ViewState 2026-06-22-00:00:
|
FNXC:ViewState 2026-06-22-00:00:
|
||||||
Workflows, Import Tasks, and Automations are promoted to top-level main-content task views (left-sidebar destinations) instead of modal-only overlays, so they render in the main panel like Command Center.
|
Workflows, Import Tasks, and Automations are promoted to top-level main-content task views (left-sidebar destinations) instead of modal-only overlays, so they render in the main panel like Command Center.
|
||||||
*/
|
*/
|
||||||
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "goalsView" | "todos" | "planning" | "skills" | "mailbox" | "insights" | "memory" | "command-center" | "secrets" | "devserver" | "dev-server" | "pull-requests" | "workflows" | "import-tasks" | "automations" | "settings" | "task-detail";
|
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "ideation" | "goalsView" | "todos" | "planning" | "skills" | "mailbox" | "insights" | "memory" | "command-center" | "secrets" | "devserver" | "dev-server" | "pull-requests" | "workflows" | "import-tasks" | "automations" | "settings" | "task-detail";
|
||||||
export type PluginTaskView = `plugin:${string}:${string}`;
|
export type PluginTaskView = `plugin:${string}:${string}`;
|
||||||
export type TaskView = BuiltInTaskView | PluginTaskView;
|
export type TaskView = BuiltInTaskView | PluginTaskView;
|
||||||
|
|
||||||
@@ -24,6 +24,12 @@ const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [
|
|||||||
"documents",
|
"documents",
|
||||||
"research",
|
"research",
|
||||||
"evals",
|
"evals",
|
||||||
|
/*
|
||||||
|
FNXC:Navigation 2026-08-01-00:00:
|
||||||
|
FN-8352 promotes Ideation from a Command Center tab to a persisted,
|
||||||
|
default-off experimental top-level view.
|
||||||
|
*/
|
||||||
|
"ideation",
|
||||||
"goalsView",
|
"goalsView",
|
||||||
/*
|
/*
|
||||||
FNXC:ViewState 2026-06-21-09:14:
|
FNXC:ViewState 2026-06-21-09:14:
|
||||||
|
|||||||
Reference in New Issue
Block a user