From 4fd8d444fb1a709a3280721be60b9b791416bb42 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 18:21:06 -0700 Subject: [PATCH] fix(dashboard): polish app chrome and workflow defaults --- .changeset/polish-dashboard-theme-roadmaps.md | 5 + .../src/__tests__/global-settings.test.ts | 6 +- .../src/__tests__/settings-defaults.test.ts | 12 + .../core/src/__tests__/store-settings.test.ts | 30 ++- packages/core/src/experimental-features.ts | 18 +- packages/core/src/settings-schema.ts | 16 +- packages/core/src/types.ts | 8 +- packages/dashboard/app/App.tsx | 86 ++++++- .../mobile-feature-access-regression.test.tsx | 2 +- .../dashboard/app/components/AppModals.tsx | 2 + .../dashboard/app/components/ChatView.css | 89 +++++++- .../dashboard/app/components/ChatView.tsx | 10 +- packages/dashboard/app/components/Header.css | 58 ++++- packages/dashboard/app/components/Header.tsx | 15 +- .../app/components/LeftSidebarNav.css | 42 +++- .../app/components/LeftSidebarNav.tsx | 9 +- .../dashboard/app/components/ListView.css | 25 +- .../app/components/MissionManager.css | 16 +- .../app/components/MissionManager.tsx | 19 +- .../app/components/ProjectOverview.css | 10 +- .../app/components/ProjectOverview.tsx | 7 +- .../app/components/SettingsModal.tsx | 35 ++- .../app/components/TaskDetailModal.css | 4 +- .../app/components/TaskDetailModal.tsx | 36 +-- .../app/components/ThemeSelector.tsx | 2 +- .../dashboard/app/components/TodoView.css | 216 ++++++++++++++++++ .../dashboard/app/components/TodoView.tsx | 33 ++- .../dashboard/app/components/ViewHeader.css | 6 +- .../app/components/WorkflowSwitcher.css | 35 +++ .../app/components/WorkflowSwitcher.tsx | 12 +- .../app/components/__tests__/App.test.tsx | 20 ++ .../components/__tests__/ChatView.test.tsx | 17 ++ .../components/__tests__/Header.css.test.ts | 10 +- .../app/components/__tests__/Header.test.tsx | 17 +- .../__tests__/LeftSidebarNav.test.tsx | 23 ++ .../MissionManager.mobile-css.test.ts | 10 +- .../__tests__/SettingsModal.test.tsx | 26 +++ .../__tests__/TaskDetailModal.test.tsx | 18 ++ .../__tests__/ThemeDropdown.test.tsx | 16 +- .../__tests__/ThemeSelector.test.tsx | 6 +- .../__tests__/WorkflowSwitcher.test.tsx | 38 ++- .../__tests__/settings-mobile.test.tsx | 2 +- .../__tests__/workflowStatusCounts.test.ts | 50 +++- .../command-center/CommandCenter.css | 37 ++- .../command-center/CommandCenter.tsx | 8 +- .../settings/sections/ExperimentalSection.tsx | 6 +- .../settings/sections/GeneralSection.tsx | 4 +- .../settings/sections/SchedulingSection.tsx | 13 +- .../dashboard/app/components/themeOptions.ts | 7 +- .../app/components/workflowStatusCounts.ts | 11 + .../app/hooks/__tests__/useTheme.test.ts | 24 +- .../dashboard/app/hooks/useAppSettings.ts | 11 + packages/dashboard/app/hooks/useTheme.ts | 16 +- packages/dashboard/app/index.html | 7 +- packages/dashboard/app/styles.css | 7 +- .../__tests__/boardScrollSnapshot.test.ts | 41 ++++ .../app/utils/boardScrollSnapshot.ts | 54 +++++ .../src/__tests__/executor-test-helpers.ts | 19 +- .../__tests__/scheduler-node-routing.test.ts | 11 +- .../engine/src/__tests__/scheduler.test.ts | 21 +- packages/engine/src/executor.ts | 7 +- packages/i18n/locales/en/app.json | 9 +- .../src/dashboard/RoadmapsView.css | 59 ++++- .../src/dashboard/RoadmapsView.tsx | 102 +++++---- .../dashboard/__tests__/RoadmapsView.test.tsx | 5 +- 65 files changed, 1348 insertions(+), 248 deletions(-) create mode 100644 .changeset/polish-dashboard-theme-roadmaps.md create mode 100644 packages/dashboard/app/utils/__tests__/boardScrollSnapshot.test.ts create mode 100644 packages/dashboard/app/utils/boardScrollSnapshot.ts diff --git a/.changeset/polish-dashboard-theme-roadmaps.md b/.changeset/polish-dashboard-theme-roadmaps.md new file mode 100644 index 0000000000..f86099c19d --- /dev/null +++ b/.changeset/polish-dashboard-theme-roadmaps.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Polish dashboard navigation, themes, roadmaps, chat, and task-detail header behavior. diff --git a/packages/core/src/__tests__/global-settings.test.ts b/packages/core/src/__tests__/global-settings.test.ts index 2427fadb83..53637992af 100644 --- a/packages/core/src/__tests__/global-settings.test.ts +++ b/packages/core/src/__tests__/global-settings.test.ts @@ -65,7 +65,7 @@ describe("GlobalSettingsStore", () => { const raw = await readFile(join(dir, "settings.json"), "utf-8"); const parsed = JSON.parse(raw); expect(parsed.themeMode).toBe("dark"); - expect(parsed.colorTheme).toBe("default"); + expect(parsed.colorTheme).toBe("ocean"); expect(parsed.ntfyEnabled).toBe(false); }); @@ -197,7 +197,7 @@ describe("GlobalSettingsStore", () => { const updated = await store.updateSettings({ themeMode: "system" }); expect(updated.themeMode).toBe("system"); - expect(updated.colorTheme).toBe("default"); // unchanged default + expect(updated.colorTheme).toBe("ocean"); // unchanged default // Verify persistence const raw = await readFile(join(dir, "settings.json"), "utf-8"); @@ -835,7 +835,7 @@ describe("GlobalSettingsStore", () => { const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8")); // Only default theme fields should be present expect(raw.themeMode).toBe("dark"); - expect(raw.colorTheme).toBe("default"); + expect(raw.colorTheme).toBe("ocean"); // Model fields should not be persisted expect(raw.defaultProvider).toBeUndefined(); expect(raw.defaultModelId).toBeUndefined(); diff --git a/packages/core/src/__tests__/settings-defaults.test.ts b/packages/core/src/__tests__/settings-defaults.test.ts index 81c92ecce4..a4f078aa43 100644 --- a/packages/core/src/__tests__/settings-defaults.test.ts +++ b/packages/core/src/__tests__/settings-defaults.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_MAX_AUTO_MERGE_RETRIES, resolveMaxAutoMergeRetries } from "../in-review-stall.js"; +import { isExperimentalFeatureEnabled } from "../experimental-features.js"; import { DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS } from "../settings-schema.js"; import { __resetLegacyCwdMainWarningForTests, @@ -26,6 +27,17 @@ describe("settings defaults invariants", () => { expect(DEFAULT_PROJECT_SETTINGS.worktreesDir).toBeUndefined(); }); + it("defaults workflow runtime flags on but dual-observe diagnostics off", () => { + expect(DEFAULT_GLOBAL_SETTINGS.experimentalFeatures.workflowColumns).toBe(true); + expect(DEFAULT_GLOBAL_SETTINGS.experimentalFeatures.workflowGraphExecutor).toBe(true); + expect(DEFAULT_GLOBAL_SETTINGS.experimentalFeatures.workflowInterpreterDualObserve).toBe(false); + expect(isExperimentalFeatureEnabled(undefined, "workflowColumns")).toBe(true); + expect(isExperimentalFeatureEnabled(undefined, "workflowGraphExecutor")).toBe(true); + expect(isExperimentalFeatureEnabled(undefined, "workflowInterpreterDualObserve")).toBe(false); + expect(isExperimentalFeatureEnabled({ experimentalFeatures: { workflowColumns: false } }, "workflowColumns")).toBe(false); + expect(isExperimentalFeatureEnabled({ experimentalFeatures: { workflowGraphExecutor: false } }, "workflowGraphExecutor")).toBe(false); + }); + it("defaults maxAutoMergeRetries to the historical project-scoped cap", () => { expect(DEFAULT_PROJECT_SETTINGS.maxAutoMergeRetries).toBe(DEFAULT_MAX_AUTO_MERGE_RETRIES); expect("maxAutoMergeRetries" in DEFAULT_GLOBAL_SETTINGS).toBe(false); diff --git a/packages/core/src/__tests__/store-settings.test.ts b/packages/core/src/__tests__/store-settings.test.ts index fabe84ae5b..d52d2862ee 100644 --- a/packages/core/src/__tests__/store-settings.test.ts +++ b/packages/core/src/__tests__/store-settings.test.ts @@ -1371,7 +1371,7 @@ describe("TaskStore", () => { it("getSettings returns global defaults when no overrides exist", async () => { const settings = await harness.store().getSettings(); expect(settings.themeMode).toBe("dark"); - expect(settings.colorTheme).toBe("default"); + expect(settings.colorTheme).toBe("ocean"); expect(settings.maxConcurrent).toBe(2); }); @@ -1997,36 +1997,42 @@ describe("TaskStore", () => { }); describe("experimentalFeatures settings", () => { - it("defaults to empty object {}", async () => { + const defaultExperimentalFeatures = { + workflowColumns: true, + workflowGraphExecutor: true, + workflowInterpreterDualObserve: false, + }; + + it("defaults workflow rollout flags to their supported runtime posture", async () => { const settings = await harness.store().getSettings(); - expect(settings.experimentalFeatures).toEqual({}); + expect(settings.experimentalFeatures).toEqual(defaultExperimentalFeatures); }); it("can set experimental features via updateGlobalSettings", async () => { await harness.store().updateGlobalSettings({ experimentalFeatures: { "my-feature": true, "another-feature": false } }); const settings = await harness.store().getSettings(); - expect(settings.experimentalFeatures).toEqual({ "my-feature": true, "another-feature": false }); + expect(settings.experimentalFeatures).toEqual({ ...defaultExperimentalFeatures, "my-feature": true, "another-feature": false }); }); it("can add and update features using merge semantics", async () => { await harness.store().updateGlobalSettings({ experimentalFeatures: { "feature-a": true } }); await harness.store().updateGlobalSettings({ experimentalFeatures: { "feature-b": true, "feature-a": false } }); const settings = await harness.store().getSettings(); - expect(settings.experimentalFeatures).toEqual({ "feature-a": false, "feature-b": true }); + expect(settings.experimentalFeatures).toEqual({ ...defaultExperimentalFeatures, "feature-a": false, "feature-b": true }); }); it("can remove an experimental feature by setting it to null", async () => { await harness.store().updateGlobalSettings({ experimentalFeatures: { "feature-a": true, "feature-b": true } }); await harness.store().updateGlobalSettings({ experimentalFeatures: { "feature-a": null } as unknown as Record }); const settings = await harness.store().getSettings(); - expect(settings.experimentalFeatures).toEqual({ "feature-b": true }); + expect(settings.experimentalFeatures).toEqual({ ...defaultExperimentalFeatures, "feature-b": true }); }); it("can clear experimentalFeatures with null", async () => { await harness.store().updateGlobalSettings({ experimentalFeatures: { "my-feature": true } }); await harness.store().updateGlobalSettings({ experimentalFeatures: null as unknown as undefined }); const settings = await harness.store().getSettings(); - expect(settings.experimentalFeatures).toEqual({}); + expect(settings.experimentalFeatures).toEqual(defaultExperimentalFeatures); }); it("preserves project settings while experimentalFeatures changes", async () => { @@ -2035,20 +2041,20 @@ describe("TaskStore", () => { const settings = await harness.store().getSettings(); expect(settings.maxConcurrent).toBe(5); expect(settings.autoMerge).toBe(false); - expect(settings.experimentalFeatures).toEqual({ "my-feature": true }); + expect(settings.experimentalFeatures).toEqual({ ...defaultExperimentalFeatures, "my-feature": true }); }); it("handles experimentalFeatures in getSettingsByScope", async () => { await harness.store().updateGlobalSettings({ experimentalFeatures: { "scoped-feature": true } }); const { global, project } = await harness.store().getSettingsByScope(); - expect(global.experimentalFeatures).toEqual({ "scoped-feature": true }); + expect(global.experimentalFeatures).toEqual({ ...defaultExperimentalFeatures, "scoped-feature": true }); expect((project as Record).experimentalFeatures).toBeUndefined(); }); it("handles experimentalFeatures in getSettingsFast", async () => { await harness.store().updateGlobalSettings({ experimentalFeatures: { "fast-feature": true } }); const settings = await harness.store().getSettingsFast(); - expect(settings.experimentalFeatures).toEqual({ "fast-feature": true }); + expect(settings.experimentalFeatures).toEqual({ ...defaultExperimentalFeatures, "fast-feature": true }); }); it("project-level experimentalFeatures does not override global value", async () => { @@ -2063,11 +2069,11 @@ describe("TaskStore", () => { // getSettingsFast should ignore the project-level global key const fastSettings = await harness.store().getSettingsFast(); - expect(fastSettings.experimentalFeatures).toEqual({ insights: true, roadmap: true }); + expect(fastSettings.experimentalFeatures).toEqual({ ...defaultExperimentalFeatures, insights: true, roadmap: true }); // getSettings should also ignore the project-level global key const settings = await harness.store().getSettings(); - expect(settings.experimentalFeatures).toEqual({ insights: true, roadmap: true }); + expect(settings.experimentalFeatures).toEqual({ ...defaultExperimentalFeatures, insights: true, roadmap: true }); }); }); diff --git a/packages/core/src/experimental-features.ts b/packages/core/src/experimental-features.ts index df815dd918..57b83855d7 100644 --- a/packages/core/src/experimental-features.ts +++ b/packages/core/src/experimental-features.ts @@ -4,21 +4,31 @@ const LEGACY_EXPERIMENTAL_FEATURE_ALIASES: Record = { devServer: "devServerView", }; +/* +FNXC:WorkflowSettings 2026-06-22-18:05: +workflowColumns and workflowGraphExecutor are now default-on rollout flags, while workflowInterpreterDualObserve remains default-off because it runs diagnostic shadow parity observation. Explicit false stays a kill switch for the two default-on runtime paths. +*/ +const DEFAULT_ON_EXPERIMENTAL_FEATURES = new Set([ + "workflowColumns", + "workflowGraphExecutor", +]); + export function isExperimentalFeatureEnabled( settings: Pick | undefined, key: string, ): boolean { const features = settings?.experimentalFeatures; - if (!features) return false; - const canonicalKey = LEGACY_EXPERIMENTAL_FEATURE_ALIASES[key] ?? key; - if (features[canonicalKey] === true) return true; + if (features?.[canonicalKey] === false) return false; + if (features?.[canonicalKey] === true) return true; for (const [legacyKey, aliasCanonical] of Object.entries(LEGACY_EXPERIMENTAL_FEATURE_ALIASES)) { - if (aliasCanonical === canonicalKey && features[legacyKey] === true) { + if (aliasCanonical === canonicalKey && features?.[legacyKey] === true) { return true; } } + if (DEFAULT_ON_EXPERIMENTAL_FEATURES.has(canonicalKey)) return true; + return false; } diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 7db3e06e28..6562f0000f 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -64,7 +64,11 @@ type ProjectSettingsSchema = Omit; /** Default values for global (user-level) settings. */ export const DEFAULT_GLOBAL_SETTINGS = { themeMode: "dark", - colorTheme: "default", + /* + FNXC:DashboardTheming 2026-06-22-18:36: + New users and unset installs should start on Ocean. Existing users who explicitly stored colorTheme "default" must remain on that legacy theme, so the id stays valid and only the absence/default seed changes to "ocean". + */ + colorTheme: "ocean", shadcnCustomColors: undefined, dashboardFontScalePct: 100, language: undefined, @@ -230,7 +234,15 @@ export const DEFAULT_GLOBAL_SETTINGS = { onFailure: "fail", }, owningNodeHandoffPolicy: "reassign-to-local", - experimentalFeatures: {}, + /* + FNXC:WorkflowSettings 2026-06-22-18:05: + New installs default to workflow columns + graph execution enabled, while dual-observe parity diagnostics are explicitly off unless an operator opts in outside the normal Settings UI. + */ + experimentalFeatures: { + workflowColumns: true, + workflowGraphExecutor: true, + workflowInterpreterDualObserve: false, + }, cliAgents: {}, } satisfies CompleteSettings; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index cd6d600e92..046ef36c8d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2959,7 +2959,7 @@ export interface WorktrunkSettings { export interface GlobalSettings { /** Theme mode preference: dark, light, or system (follows OS). Default: "dark". */ themeMode?: ThemeMode; - /** Color theme preference for accent colors and styling. Default: "default". */ + /** Color theme preference for accent colors and styling. Default: "ocean"; "default" is the legacy Fusion theme id. */ colorTheme?: ColorTheme; /** Token→hex override map for the customizable shadcn theme. Applied only when `colorTheme === "shadcn-custom"`; dashboard sanitizes keys and values before writing CSS custom properties. */ shadcnCustomColors?: Record; @@ -3353,9 +3353,9 @@ export interface GlobalSettings { * "another-experiment": false * } * - * Default: workflow columns, graph executor, dual-observe, authoritative - * interpreter, and `claudeCliAcp` flags enabled; operators may explicitly set - * individual flags false while rollout controls remain available. + * Default: workflow columns and graph executor enabled; dual-observe remains + * disabled because it runs diagnostic shadow parity observation. Operators may + * explicitly set individual rollout flags false while controls remain available. * * `claudeCliAcp` (default ON): routes the Claude CLI provider through the * `claude-code-cli-acp` ACP bridge instead of `claude -p`. Effective only when diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 27a4f665fa..8e06c63ed6 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -71,6 +71,11 @@ import { useAuthOnboarding } from "./hooks/useAuthOnboarding"; import { useMobileKeyboard } from "./hooks/useMobileKeyboard"; import { isIOS, useMobileKeyboardViewportLock, useMobileViewportRestoreReset } from "./hooks/useMobileScrollLock"; import { computeMobileBarKeyboardFlags } from "./utils/mobileBarKeyboardFlags"; +import { + captureBoardScrollSnapshot, + restoreBoardScrollSnapshot, + type BoardScrollSnapshot, +} from "./utils/boardScrollSnapshot"; import { useSetupReadiness } from "./hooks/useSetupReadiness"; import { useUpdateCheck } from "./hooks/useUpdateCheck"; import { useViewState, type TaskView } from "./hooks/useViewState"; @@ -94,7 +99,7 @@ import { NativeShellOnboardingModal } from "./components/NativeShellOnboardingMo import { NativeShellConnectionManager } from "./components/NativeShellConnectionManager"; import { ShellConnectionStatus } from "./components/ShellConnectionStatus"; import { getShellConnectionNativeResult, type ShellConnectionNativeResult } from "./shell-native"; -import type { AiSessionSummary, DashboardHealthResponse } from "./api"; +import type { AiSessionSummary, DashboardHealthResponse, PluginDashboardViewEntry } from "./api"; import { api, fetchDashboardHealth, fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps, refreshDashboardHealth, relaunchCliSession } from "./api"; import { getScopedItem, removeScopedItem, setScopedItem } from "./utils/projectStorage"; import { subscribeSse } from "./sse-bus"; @@ -505,10 +510,10 @@ function AppInner() { setThemeMode, }); - const { views: pluginDashboardViews } = usePluginDashboardViews(currentProject?.id); + const { views: rawPluginDashboardViews } = usePluginDashboardViews(currentProject?.id); const graphPluginTaskView = useMemo(() => { // Prefer API response for the graph view (supports dynamic plugin discovery) - const graphView = pluginDashboardViews.find( + const graphView = rawPluginDashboardViews.find( (entry) => entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph", ); if (graphView) return `plugin:${graphView.pluginId}:${graphView.view.viewId}` as const; @@ -518,7 +523,7 @@ function AppInner() { return `plugin:fusion-plugin-dependency-graph:graph` as const; } return null; - }, [pluginDashboardViews]); + }, [rawPluginDashboardViews]); // History-aware view change handler — pushes nav entry on back-navigation stack. const handleTaskViewChange = useCallback((newView: TaskView) => { @@ -553,6 +558,41 @@ function AppInner() { Snapshot of the task whose detail is shown in the main panel (Board card click → full-panel detail). Kept as a snapshot so the view survives a tasks revalidation; renderMainContent prefers the live row from `tasks` by id and falls back to this snapshot. */ const [mainPanelDetailTask, setMainPanelDetailTask] = useState(null); + const boardScrollSnapshotRef = useRef(null); + const pendingBoardScrollRestoreRef = useRef(false); + + const captureCurrentBoardScrollSnapshot = useCallback(() => { + boardScrollSnapshotRef.current = captureBoardScrollSnapshot(); + }, []); + + const restoreCurrentBoardScrollSnapshot = useCallback(() => { + if (restoreBoardScrollSnapshot(boardScrollSnapshotRef.current)) { + pendingBoardScrollRestoreRef.current = false; + } + }, []); + + useEffect(() => { + if (taskView !== "board" || !pendingBoardScrollRestoreRef.current) return; + const scheduleFrame = typeof window.requestAnimationFrame === "function" + ? window.requestAnimationFrame.bind(window) + : ((callback: FrameRequestCallback) => window.setTimeout(() => callback(performance.now()), 0)); + const cancelFrame = typeof window.cancelAnimationFrame === "function" + ? window.cancelAnimationFrame.bind(window) + : window.clearTimeout.bind(window); + let firstFrame = 0; + let secondFrame = 0; + /* + FNXC:BoardNavigation 2026-06-22-20:15: + Board-card task detail replaces the board instead of overlaying it. Preserve horizontal board scroll and per-column vertical scroll before opening detail, then restore after Back to board remounts the board so users return to the same lane/card context. + */ + firstFrame = scheduleFrame(() => { + secondFrame = scheduleFrame(restoreCurrentBoardScrollSnapshot); + }); + return () => { + cancelFrame(firstFrame); + cancelFrame(secondFrame); + }; + }, [restoreCurrentBoardScrollSnapshot, taskView]); /* FNXC:FloatingWindow 2026-06-22-20:45: @@ -1003,10 +1043,37 @@ function AppInner() { devServerEnabled, todosEnabled, goalsEnabled, + setQuickChatButtonModeImmediate, toggleAutoMerge, refresh: refreshAppSettings, } = useAppSettings(currentProject?.id); + const pluginDashboardViews = useMemo(() => { + /* + FNXC:RoadmapsNavigation 2026-06-22-18:00: + Enabling the Roadmaps experiment must make Roadmaps appear under Missions even when the plugin dashboard-view endpoint has not returned the bundled roadmap plugin. Synthesize the bundled view client-side as a fallback; API-provided plugin views still win when present. + */ + if (experimentalFeatures.roadmap !== true) return rawPluginDashboardViews; + const hasRoadmaps = rawPluginDashboardViews.some( + (entry) => entry.pluginId === "fusion-plugin-roadmap" && entry.view.viewId === "roadmaps", + ); + if (hasRoadmaps) return rawPluginDashboardViews; + return [ + ...rawPluginDashboardViews, + { + pluginId: "fusion-plugin-roadmap", + view: { + viewId: "roadmaps", + label: "Roadmaps", + componentPath: "./dashboard-view", + icon: "Map", + placement: "primary", + order: 30, + }, + }, + ]; + }, [experimentalFeatures.roadmap, rawPluginDashboardViews]); + const { stats: agentStats } = useAgents(currentProject?.id); const inProgressCount = useMemo( @@ -1076,8 +1143,8 @@ function AppInner() { Left sidebar navigation is now the default primary navigation on non-mobile project screens. Keep `leftSidebarNav: false` as the explicit opt-out and keep mobile on the bottom navigation bar. */ const leftSidebarNavEnabled = experimentalFeatures.leftSidebarNav !== false; - /* FNXC:Navigation 2026-06-21-00:00: The default-on right dock makes tablet/desktop More views toggle a persistent right panel unless settings store `rightDock: false`; mobile remains legacy. */ - const rightDockEnabled = experimentalFeatures.rightDock !== false; + /* FNXC:Navigation 2026-06-22-18:00: The right dock panel is no longer experimental or user-toggleable; tablet/desktop project screens always support it regardless of any stale persisted `rightDock` setting. */ + const rightDockEnabled = true; const executorFooterVisible = viewMode === "project" && !!currentProject; const rightDockActive = rightDockEnabled && !isMobile && executorFooterVisible; const sidebarActive = leftSidebarNavEnabled && !isMobile && executorFooterVisible; @@ -1282,12 +1349,14 @@ function AppInner() { Board card clicks open task detail as a full main-content view that replaces the board (design: "Full main panel (replaces board)"), instead of the TaskDetailModal overlay. We store a snapshot of the clicked task and navigate to the registered `task-detail` view; renderMainContent renders TaskDetailContent embedded with a Back-to-board button. Only the Board uses this handler — list-view split-detail, right-dock cards, and other openDetail callers keep the modal behavior. */ const openTaskDetailInMainPanel = useCallback((task: Task | TaskDetail) => { + captureCurrentBoardScrollSnapshot(); setMainPanelDetailTask(task); handleTaskViewChange("task-detail"); - }, [handleTaskViewChange]); + }, [captureCurrentBoardScrollSnapshot, handleTaskViewChange]); // FNXC:Navigation 2026-06-22-00:00: Leaving task-detail clears the snapshot so a stale task never lingers if the view is reopened empty. const closeTaskDetailMainPanel = useCallback(() => { + pendingBoardScrollRestoreRef.current = true; setMainPanelDetailTask(null); handleTaskViewChange("board"); }, [handleTaskViewChange]); @@ -1588,6 +1657,7 @@ function AppInner() { resolvedThemeMode={resolvedThemeMode} onDashboardFontScaleChange={setDashboardFontScalePct} onShadcnCustomColorsChange={setShadcnCustomColors} + onQuickChatButtonModeChange={setQuickChatButtonModeImmediate} onReopenOnboarding={reopenOnboardingWithNav} onOpenApprovals={() => handleChangeTaskView("mailbox")} onOpenWorkflowSettings={() => { @@ -2609,7 +2679,7 @@ function AppInner() { onSubtaskBreakdown={subtaskBreakdownEnabled ? openSubtaskBreakdownWithNav : undefined} taskOperations={{ moveTask, deleteTask, mergeTask, archiveTask, retryTask, resetTask, duplicateTask }} deepLink={{ handleDetailClose }} - settings={{ prAuthAvailable, autoMerge, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors, resolvedThemeMode, setThemeMode, setColorTheme, setDashboardFontScalePct, setShadcnCustomColors }} + settings={{ prAuthAvailable, autoMerge, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors, resolvedThemeMode, setThemeMode, setColorTheme, setDashboardFontScalePct, setShadcnCustomColors, setQuickChatButtonModeImmediate }} onSettingsClose={handleSettingsCloseWithNav} onReopenOnboarding={reopenOnboardingWithNav} onOpenApprovals={(_approvalId) => handleTaskViewChange("mailbox")} diff --git a/packages/dashboard/app/__tests__/mobile-feature-access-regression.test.tsx b/packages/dashboard/app/__tests__/mobile-feature-access-regression.test.tsx index a47931e277..1166033fb1 100644 --- a/packages/dashboard/app/__tests__/mobile-feature-access-regression.test.tsx +++ b/packages/dashboard/app/__tests__/mobile-feature-access-regression.test.tsx @@ -335,7 +335,7 @@ describe("Mobile Feature Access Regression Guard", () => { } }); - it("right dock flag off keeps the desktop and tablet More views chevron dropdown", () => { + it("keeps the desktop and tablet More views chevron dropdown when the right dock is unavailable", () => { for (const tier of ["desktop", "tablet"] as const) { mockViewport(tier); const { unmount } = render( diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index 69d0d0bf5f..956da05470 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -82,6 +82,7 @@ interface AppModalsProps { setColorTheme: (theme: ColorTheme) => void; setDashboardFontScalePct: (scalePct: number) => void; setShadcnCustomColors: (colors: Record) => void; + setQuickChatButtonModeImmediate: (mode: "floating" | "footer" | "off") => void; }; /** Optional override for the settings modal close handler. When provided, this is called instead of modalManager.closeSettings. */ onSettingsClose?: () => void; @@ -332,6 +333,7 @@ export function AppModals({ resolvedThemeMode={settings.resolvedThemeMode} onDashboardFontScaleChange={settings.setDashboardFontScalePct} onShadcnCustomColorsChange={settings.setShadcnCustomColors} + onQuickChatButtonModeChange={settings.setQuickChatButtonModeImmediate} onReopenOnboarding={onReopenOnboarding} onOpenApprovals={onOpenApprovals} onOpenWorkflowSettings={() => { diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index 25b17c60ff..3b1c2f5ed7 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -1,6 +1,7 @@ /* ── Chat View ─────────────────────────────────────────────────────────────── */ .chat-view { + container: chat-view / inline-size; display: flex; flex-direction: column; flex: 1 1 auto; @@ -67,12 +68,17 @@ display: flex; gap: var(--space-xs); padding: var(--space-sm) var(--space-md); - border-bottom: 1px solid var(--border); } /* FNXC:ChatHeader 2026-06-22-16:18: Direct/Rooms now lives in the Chat ViewHeader immediately before New Chat. The control must scale with available header width: bounded flex-basis, minmax grid columns, and truncating labels let it fit desktop, narrow pop-out, and mobile headers without forcing the title/actions to overlap. + +FNXC:ChatHeader 2026-06-22-18:44: +Keep the Direct/Rooms segmented control height-aligned with ViewHeader's action row and collapse labels to icons when Chat is very narrow. Buttons use height:100% inside the padded track so they cannot grow taller than the background. + +FNXC:ChatHeader 2026-06-22-20:28: +When the movable chat popup is resized narrow, collapse Direct/Rooms labels to icon-only from the ChatView container width so the Chat title remains visible even on a wide desktop viewport. */ .chat-view-header-scope-toggle { display: grid; @@ -81,6 +87,8 @@ Direct/Rooms now lives in the Chat ViewHeader immediately before New Chat. The c width: clamp(128px, 24vw, 220px); min-width: min(128px, 100%); max-width: 220px; + height: var(--view-header-content-row, 28px); + box-sizing: border-box; padding: 2px; border: 1px solid var(--border); border-radius: var(--radius-md); @@ -99,9 +107,13 @@ Direct/Rooms now lives in the Chat ViewHeader immediately before New Chat. The c } .chat-view-header-scope-toggle .chat-sidebar-scope-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-xs); min-width: 0; min-height: 0; - height: var(--view-header-content-row, 28px); + height: 100%; padding: 0 clamp(var(--space-xs), 1.2vw, var(--space-sm)); border: 1px solid transparent; border-radius: calc(var(--radius-md) - 2px); @@ -111,6 +123,16 @@ Direct/Rooms now lives in the Chat ViewHeader immediately before New Chat. The c font-size: 12px; } +.chat-view-header-scope-toggle .chat-sidebar-scope-btn svg { + flex: 0 0 auto; +} + +.chat-view-header-scope-toggle .chat-sidebar-scope-btn span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + .chat-sidebar-scope-btn:hover { background: var(--card-hover); color: var(--text); @@ -139,7 +161,6 @@ Direct/Rooms now lives in the Chat ViewHeader immediately before New Chat. The c .chat-sidebar-rooms-header { padding: var(--space-sm) var(--space-md); - border-bottom: 1px solid var(--border); } .chat-sidebar-rooms-empty { @@ -708,6 +729,52 @@ Mobile chat session switching needs a dedicated rename tap target beside each se } } +@container chat-view (max-width: 560px) { + .chat-view-header-scope-toggle { + flex: 0 0 72px; + width: 72px; + min-width: 72px; + max-width: 72px; + } + + .chat-view-header-scope-toggle .chat-sidebar-scope-btn { + padding: 0; + } + + .chat-view-header-scope-toggle .chat-sidebar-scope-btn span { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + } +} + +@media (max-width: 460px) { + .chat-view-header-scope-toggle { + flex: 0 0 72px; + width: 72px; + min-width: 72px; + max-width: 72px; + } + + .chat-view-header-scope-toggle .chat-sidebar-scope-btn { + padding: 0; + } + + .chat-view-header-scope-toggle .chat-sidebar-scope-btn span { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + } +} + /* FNXC:ChatModal 2026-06-22-13:22: The old Quick Chat panel is replaced by the full ChatView inside a movable FloatingWindow. In floating mode ChatView's shared header is the only visible modal header and doubles as the drag handle, with minimize/close controls in the same action row. @@ -759,6 +826,22 @@ Floating Chat can become narrow while the browser viewport remains desktop-sized display: none; } +/* +FNXC:ChatModal 2026-06-22-18:00: +In the narrow/mobile chat layout there is no room for an expand/maximize affordance in the header. Hide only the expand controls (main Chat pop-out and floating-modal maximize); keep minimize/close visible so the user can still dismiss or dock the chat. +*/ +.chat-view--narrow [data-testid="chat-pop-out"], +.chat-view--narrow [data-testid="chat-modal-maximize"] { + display: none; +} + +@media (max-width: 768px) { + .chat-view [data-testid="chat-pop-out"], + .chat-view [data-testid="chat-modal-maximize"] { + display: none; + } +} + .chat-rename-label { display: block; margin-bottom: var(--space-xs); diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 4cf5b1de9c..3ffe0c6621 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -28,6 +28,7 @@ import { Maximize2, Minimize2, X, + Hash, } from "lucide-react"; import { useChat, type ChatMessageInfo, type FailureInfo, type ToolCallInfo } from "../hooks/useChat"; import { RoomMessageDeliveredButReplyFailedError, useChatRooms } from "../hooks/useChatRooms"; @@ -3219,6 +3220,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures, floating = /* FNXC:ChatHeader 2026-06-22-16:18: Direct/Rooms is a view-level scope switch, so it belongs in Chat's canonical header directly before New Chat instead of consuming the first row of the sidebar. Keep the existing test ids while moving the DOM so direct and room conversations share one header control surface. + + FNXC:ChatHeader 2026-06-22-18:44: + Very narrow chat headers collapse Direct/Rooms to icons while retaining aria-selected tabs and text labels for wider headers. The segmented control must stay height-aligned with the ViewHeader action row, so icon+label markup is stable and CSS hides only the label. */ const scopeToggle = chatRoomsEnabled ? (
@@ -3230,7 +3234,8 @@ export function ChatView({ projectId, addToast, experimentalFeatures, floating = data-testid="chat-sidebar-scope-direct" onClick={() => setChatScope("direct")} > - {t("chat.scopeDirect", "Direct")} +
) : null; diff --git a/packages/dashboard/app/components/Header.css b/packages/dashboard/app/components/Header.css index 4ac18b4d4e..fd767364c3 100644 --- a/packages/dashboard/app/components/Header.css +++ b/packages/dashboard/app/components/Header.css @@ -1,14 +1,17 @@ /* === Header === */ /* FNXC:DashboardHeader 2026-06-22-14:30: -The dashboard top header should carry the same canonical bottom divider as Missions, Planning, and other view headers. Theme-level CSS may hide this divider for seamless themes such as Air and Shadcn, but the base header must expose the divider for themes that support visible separation. +Superseded by the 19:00 shell requirement below. View-level headers such as Missions, Planning, Dashboard, and Project Dashboard carry the theme-controlled divider; the global Fusion shell header no longer owns that separator. + +FNXC:DashboardHeader 2026-06-22-19:00: +The global Fusion shell header should not draw a divider between itself and the sidebar/main content row. View-level headers keep their own dividers; this top shell bar blends into the surface above the navigation/content split. */ .header { display: flex; align-items: center; justify-content: space-between; padding: var(--header-padding); - border-bottom: 1px solid var(--border); + border-bottom: none; background: var(--surface); } @@ -128,9 +131,60 @@ non-notched devices, so this is a no-op there. Pair with viewport-fit=cover (ind } @media (max-width: 768px) { + /* + FNXC:WorkflowControls 2026-06-22-18:00: + Mobile places the workflow dropdown in the top header beside the logo/project switch. Hide only the text label, clamp the trigger width, and keep the transparent project-selector-style chrome so the row fits without pushing search/usage controls offscreen. + */ + .header-left { + min-width: 0; + gap: var(--space-xs); + } + + .header-brand { + min-width: 0; + flex-shrink: 0; + } + .header-workflow-slot { + flex: 1 1 auto; + justify-content: flex-start; + min-width: 0; + max-width: min(42vw, calc(var(--space-2xl) * 4)); + } + + .header-workflow-slot:empty { display: none; } + + .header-workflow-slot .workflow-switcher { + width: 100%; + min-width: 0; + } + + .header-workflow-slot .workflow-switcher-label { + display: none; + } + + .header-workflow-slot .workflow-switcher-trigger { + width: 100%; + max-width: 100%; + min-height: calc(var(--space-lg) + var(--space-xs)); + padding: calc(var(--space-xs) / 2) var(--space-xs); + font-size: 12px; + } + + .header-workflow-slot .workflow-switcher-trigger-main { + gap: var(--space-xs); + } + + .header-workflow-slot .workflow-switcher-counts { + display: none; + } + + .header-workflow-slot .workflow-switcher-merging-indicator { + width: calc(var(--space-sm) - 2px); + height: calc(var(--space-sm) - 2px); + } } .quick-scripts-dropdown { diff --git a/packages/dashboard/app/components/Header.tsx b/packages/dashboard/app/components/Header.tsx index 1dce747dd9..a15231ece4 100644 --- a/packages/dashboard/app/components/Header.tsx +++ b/packages/dashboard/app/components/Header.tsx @@ -178,6 +178,9 @@ export function Header({ FNXC:WorkflowControls 2026-06-20-00:00: The hidden Header view-toggle location becomes the workflow-control portal slot only when left sidebar navigation is active on tablet/desktop. Mobile and flag-off paths keep workflow controls inline so the board/list chrome remains byte-identical. + + FNXC:WorkflowControls 2026-06-22-18:00: + Mobile also renders the workflow portal in the top header next to the logo/project switch. The board/list workflow selector stays single-sourced through this slot, while CSS hides the "Workflow" label and compacts the trigger so it fits the mobile header. */ const hideHeaderViewNav = leftSidebarNavActive && !isMobile; /* @@ -460,6 +463,14 @@ export function Header({ )} + {hideFullNav && ( +
+ )} + {/* Project Selector - Back button when project selected, dropdown when 2+ projects (tablet + desktop) */} {!isMobile && projects.length >= 1 && onViewAllProjects && ( onChangeView("command-center")} - title={t("header.commandCenterView", "Command Center")} - aria-label={t("header.commandCenterView", "Command Center")} + title={t("header.commandCenterView", "Dashboard")} + aria-label={t("header.commandCenterView", "Dashboard")} aria-pressed={view === "command-center"} data-testid="view-toggle-command-center" > diff --git a/packages/dashboard/app/components/LeftSidebarNav.css b/packages/dashboard/app/components/LeftSidebarNav.css index 616450687b..2938c8faf9 100644 --- a/packages/dashboard/app/components/LeftSidebarNav.css +++ b/packages/dashboard/app/components/LeftSidebarNav.css @@ -1,6 +1,9 @@ /* FNXC:Navigation 2026-06-19-00:00: The experimental sidebar is a persistent desktop/tablet navigation replacement for the Header view-toggle row. It uses the same tokenized visual rhythm as Header navigation so the flag can be toggled without changing dashboard information architecture. + +FNXC:Navigation 2026-06-22-18:00: +The border between left sidebar and main content should be invisible while the sidebar remains draggable. Keep the resize handle's invisible hit target and hover/focus accent, but remove the persistent vertical rule and footer divider so the shell reads as one continuous surface. */ .left-sidebar-nav { --left-sidebar-nav-width: calc(var(--space-2xl) * 7); @@ -13,7 +16,6 @@ The experimental sidebar is a persistent desktop/tablet navigation replacement f min-width: var(--left-sidebar-nav-width); min-height: 0; background: var(--surface); - border-right: 1px solid var(--border); color: var(--text); } @@ -80,6 +82,40 @@ With the secondary divider removed the nav reads as one continuous list, so the min-height: 0; overflow-y: auto; padding: var(--space-sm); + scrollbar-color: transparent transparent; + scrollbar-width: thin; + transition: scrollbar-color var(--transition-fast); +} + +/* +FNXC:Navigation 2026-06-22-18:38: +The left sidebar scrollbar should stay out of sight until the user is interacting with that scroll area. Reveal it on hover/focus/active scrolling while preserving the scroll gutter so nav labels do not shift. +*/ +.left-sidebar-nav__list:hover, +.left-sidebar-nav__list:focus-within, +.left-sidebar-nav__list:active { + scrollbar-color: color-mix(in srgb, var(--text-muted) 38%, transparent) transparent; +} + +.left-sidebar-nav__list::-webkit-scrollbar { + width: 8px; +} + +.left-sidebar-nav__list::-webkit-scrollbar-track { + background: transparent; +} + +.left-sidebar-nav__list::-webkit-scrollbar-thumb { + background: transparent; + border: 2px solid transparent; + border-radius: 999px; + background-clip: padding-box; +} + +.left-sidebar-nav__list:hover::-webkit-scrollbar-thumb, +.left-sidebar-nav__list:focus-within::-webkit-scrollbar-thumb, +.left-sidebar-nav__list:active::-webkit-scrollbar-thumb { + background-color: color-mix(in srgb, var(--text-muted) 38%, transparent); } .left-sidebar-nav__section { @@ -177,6 +213,9 @@ The narrower resizable sidebar must preserve row rhythm by truncating labels ins /* FNXC:Navigation 2026-06-22-00:00: The footer stacks the Collapse toggle above Settings. Use a flex column with a small gap so the two controls are visually separated instead of butting directly against each other. + +FNXC:Navigation 2026-06-22-18:00: +The footer has no top divider; it remains a functional footer cluster but should not draw a line at its top edge. */ .left-sidebar-nav__footer { display: flex; @@ -184,7 +223,6 @@ The footer stacks the Collapse toggle above Settings. Use a flex column with a s gap: var(--space-xs); margin-top: auto; padding: var(--space-sm); - border-top: 1px solid var(--border); } .left-sidebar-nav__settings { diff --git a/packages/dashboard/app/components/LeftSidebarNav.tsx b/packages/dashboard/app/components/LeftSidebarNav.tsx index def4c28c37..60981243a2 100644 --- a/packages/dashboard/app/components/LeftSidebarNav.tsx +++ b/packages/dashboard/app/components/LeftSidebarNav.tsx @@ -262,8 +262,11 @@ export function LeftSidebarNav({ const compoundPluginEntry = sortedPluginViews.find( (entry) => entry.pluginId === "fusion-plugin-compound-engineering", ); + const roadmapPluginEntry = sortedPluginViews.find( + (entry) => entry.pluginId === "fusion-plugin-roadmap" && entry.view.viewId === "roadmaps", + ); const remainingPluginViews = sortedPluginViews.filter( - (entry) => entry !== graphPluginEntry && entry !== compoundPluginEntry, + (entry) => entry !== graphPluginEntry && entry !== compoundPluginEntry && entry !== roadmapPluginEntry, ); /* @@ -273,6 +276,9 @@ export function LeftSidebarNav({ 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. + + FNXC:Navigation 2026-06-22-17:40: + Roadmaps is a planning-adjacent plugin view, not a trailing plugin utility. When selected/enabled it appears immediately under Missions so the Planning/Missions/Roadmaps hierarchy stays together in the left sidebar. */ const navEntries: SidebarNavEntry[] = [ /* @@ -329,6 +335,7 @@ export function LeftSidebarNav({ testId: "sidebar-nav-missions", onSelect: () => onChangeView("missions"), }, + ...(roadmapPluginEntry ? [mapPluginEntry(roadmapPluginEntry)] : []), ...(showAgentsTab ? [ { diff --git a/packages/dashboard/app/components/ListView.css b/packages/dashboard/app/components/ListView.css index 11382239b8..9bd2042f86 100644 --- a/packages/dashboard/app/components/ListView.css +++ b/packages/dashboard/app/components/ListView.css @@ -261,7 +261,6 @@ Compact count for the single header row: smaller, dimmer, and non-dominant so th width: 100%; padding: var(--space-md) var(--space-xl); background: var(--surface); - border-bottom: 1px solid var(--border); } .list-create-area .quick-entry-box { @@ -270,12 +269,12 @@ Compact count for the single header row: smaller, dimmer, and non-dominant so th } /* New class for QuickEntryBox positioned above the table in list view. - FNXC:ListView 2026-06-23-04:00: Reduced top padding so the quick-add box sits close to the toolbar above it. */ + FNXC:ListView 2026-06-23-04:00: Reduced top padding so the quick-add box sits close to the toolbar above it. + FNXC:ListView 2026-06-22-18:00: Remove the divider between quick add and the table title/columns so quick-create flows into the list header without an extra horizontal rule. */ .list-quick-entry-above-table { width: 100%; padding: var(--space-xs) var(--space-xl) var(--space-md); background: var(--surface); - border-bottom: 1px solid var(--border); } .list-quick-entry-above-table .quick-entry-box { @@ -425,17 +424,20 @@ Compact count for the single header row: smaller, dimmer, and non-dominant so th /* FNXC:ListView 2026-06-22-00:40: Widen the split resize column so the task-list sidebar is easy to grab and drag (the 4px --space-xs target was hard to hit). The handle shows a centered grip line that brightens on hover/focus. + +FNXC:ListView 2026-06-22-18:00: +List view should not show a wide divider gutter. Collapse the grid handle column to zero width and let the transparent resize handle overlap the single 1px sidebar border, preserving a comfortable touch target without making the visible divider wider. */ .list-split-layout { display: grid; - grid-template-columns: auto var(--space-sm) minmax(0, 1fr); + grid-template-columns: auto 0 minmax(0, 1fr); height: 100%; min-height: 0; } /* -FNXC:SidebarDivider 2026-06-23-02:45: -The static 1px divider line lives on the sidebar pane's right border, IDENTICAL to Chat where `.chat-sidebar` carries `border-right: 1px solid var(--border)`. Previously List painted the visible line via the handle `::after`, which rendered at a different position/edge than Chat's pane border; moving it to the pane border reconciles the two so the 1px var(--border) line matches exactly in thickness, color, and position. +FNXC:SidebarDivider 2026-06-22-18:00: +The static 1px divider line lives on the sidebar pane's right border while the resize handle column has no visible width. This keeps one normal divider line between the task list and detail pane without a large gutter. */ .list-split-sidebar { min-width: 0; @@ -445,14 +447,17 @@ The static 1px divider line lives on the sidebar pane's right border, IDENTICAL } /* -FNXC:SidebarDivider 2026-06-23-02:45: -The List split divider is now IDENTICAL to Chat's `.chat-sidebar-resize-handle`: the handle itself is a transparent var(--space-sm)-wide drag hit-area (no persistent background, no persistent visible line). The static 1px var(--border) divider line is drawn by the sibling sidebar pane's border-right (see .list-split-sidebar above), exactly as Chat draws it on .chat-sidebar. A centered var(--space-xs) ::before band tints (color-mix --todo 30%) only on hover/active — matching Chat's ::before tint. focus-visible keeps the keyboard ring (the List handle is a focusable resize control with aria) and also tints the band so keyboard resizers get the same affordance. +FNXC:SidebarDivider 2026-06-22-18:00: +The List split resize control is a transparent var(--space-sm)-wide drag hit-area that overlaps the single pane border. It tints a narrow centered band only on hover/active/focus, matching the left sidebar handle's "large target, slim visual" behavior without widening the visible divider. */ .list-split-resize-handle { position: relative; + left: calc(var(--space-sm) / -2); + width: var(--space-sm); cursor: col-resize; background: transparent; touch-action: none; + z-index: 2; transition: background var(--transition-fast); } @@ -478,8 +483,8 @@ The List split divider is now IDENTICAL to Chat's `.chat-sidebar-resize-handle`: } /* -FNXC:SidebarDivider 2026-06-23-02:45: -No border-left on the detail pane. Chat draws the single divider line solely on the sidebar pane's border-right; the thread pane is unbordered. Keeping a border-left here would produce a second faint line on the far edge of the handle track (a double divider), so it is removed to make the rendered line match Chat exactly (one 1px var(--border) line at the sidebar's right edge). +FNXC:SidebarDivider 2026-06-22-18:00: +No border-left on the detail pane. Keeping a border-left here would produce a second faint line after the overlapping resize handle, so the list/detail split renders as one divider only. */ .list-split-detail { min-width: 0; diff --git a/packages/dashboard/app/components/MissionManager.css b/packages/dashboard/app/components/MissionManager.css index fb90ab2b97..63a624a435 100644 --- a/packages/dashboard/app/components/MissionManager.css +++ b/packages/dashboard/app/components/MissionManager.css @@ -56,7 +56,10 @@ /* ── Header ── */ /* FNXC:MissionManager 2026-06-23-03:00: -Exactly ONE divider sits under the Missions header — this header's own border-bottom. The scroll body, split, sidebar, and detail-pane add NO border-top, so there is no doubled/extra divider line below the header (verified in DOM: only this element carries a horizontal border in the header region). Planning's embedded header is matched to this single-divider treatment. Do not add a second border-top to the body/split/sidebar. +Missions previously relied on the header's own border-bottom as the only divider. FNXC:MissionManager 2026-06-22-18:12 updates the main-content embedded view to remove the extra line below the header entirely; internal sidebar/detail pane borders remain, but the area directly under the top header is seamless. + +FNXC:MissionManager 2026-06-22-18:00: +Mission headers define the shared header color: var(--surface), with no bottom divider. Other headers should match this background instead of drawing a separate line after the title row. */ /* FNXC:ViewHeader 2026-06-23-04:15: @@ -70,8 +73,7 @@ Pin the canonical --view-header-min-height (≈61px border-box) + box-sizing so justify-content: space-between; min-height: var(--view-header-min-height); padding: var(--space-md) var(--space-lg); - border-bottom: 1px solid var(--border); - background: color-mix(in srgb, var(--bg) 10%, transparent); + background: var(--surface); } /* Inline mode header - matches agents-view-header styling (canonical ViewHeader padding). */ @@ -531,10 +533,10 @@ Title metric matches the shared ViewHeader (1.125rem) so the Missions header rea width: auto; } -.mission-list__top-action { - display: flex; -} - +/* +FNXC:MissionsMobile 2026-06-22-18:00: +Narrow/mobile Missions puts Plan New Mission at the bottom of the list, using the same compact primary button proportions as Chat's New Chat action instead of a large top CTA. +*/ .mission-list__primary-cta { width: 100%; justify-content: center; diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index 7846456d9f..8da4285fdb 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -4483,8 +4483,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr const renderMissionListContent = ({ hideBottomButtons = false }: { hideBottomButtons?: boolean } = {}) => { const persistedInterviewMissions = missions.filter((mission) => mission.interviewState === "in_progress"); const standardMissions = missions.filter((mission) => mission.interviewState !== "in_progress"); - const showMobileTopPlanButton = isMobile && missions.length > 0 && !isCreatingMission; - const showBottomPlanButton = !hideBottomButtons && !showMobileTopPlanButton; + const showBottomPlanButton = !hideBottomButtons; return (
@@ -4568,18 +4567,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
)} - {showMobileTopPlanButton && ( -
- -
- )} - {/* Mission and interview items */} {missionInterviewDrafts.length > 0 && (
@@ -4715,8 +4702,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{showBottomPlanButton && (
-
diff --git a/packages/dashboard/app/components/ProjectOverview.css b/packages/dashboard/app/components/ProjectOverview.css index 23dd7f071c..a00568e5f8 100644 --- a/packages/dashboard/app/components/ProjectOverview.css +++ b/packages/dashboard/app/components/ProjectOverview.css @@ -18,15 +18,11 @@ FNXC:DashboardHeader 2026-06-22-16:55: The Dashboard header itself must remain the same full-width chrome as other main views (surface background, divider, shared padding from ViewHeader). Only the content below is centered/constrained; never put the header inside the 1400px overview body. FNXC:DashboardHeader 2026-06-22-17:20: -Keep Dashboard on the canonical header model instead of changing every other view to match a flat Dashboard. Use a low-specificity :where() selector so theme-level Air/shadcn divider suppression can still override the divider color while Dashboard gets the same surface, single bottom divider, and full-width header chrome as the rest of the app. +Keep Dashboard on the canonical header model instead of changing every other view to match a flat Dashboard. The shared ViewHeader owns the shaded surface and single bottom divider so Dashboard matches Missions and Chat; this file only keeps the header full-width and prevents local overview body constraints from changing that chrome. */ .project-overview > :where(.view-header) { width: 100%; flex: 0 0 auto; - background: var(--surface); - border-bottom-style: solid; - border-bottom-width: 1px; - border-bottom-color: var(--border); } .project-overview__body { @@ -49,10 +45,6 @@ Keep Dashboard on the canonical header model instead of changing every other vie justify-content: center; } -.project-overview--loading { - padding: var(--space-xl); -} - /* --- Overview Header --- */ .project-overview__header { display: flex; diff --git a/packages/dashboard/app/components/ProjectOverview.tsx b/packages/dashboard/app/components/ProjectOverview.tsx index ecff3de35e..7568b19721 100644 --- a/packages/dashboard/app/components/ProjectOverview.tsx +++ b/packages/dashboard/app/components/ProjectOverview.tsx @@ -238,12 +238,15 @@ export function ProjectOverview({ const needsInitialSkeleton = loading || (healthLoading && projects.length > 0 && Object.keys(healthMap).length === 0); /* FNXC:DashboardHeader 2026-06-22-16:42: - The Dashboard overview (projects, stats, filters, and charts/overview content) owns the shared top header. The Board view must stay headerless because its columns already consume the full board surface. + The Project Dashboard overview (projects, stats, filters, and charts/overview content) owns the shared top header. The Board view must stay headerless because its columns already consume the full board surface. + + FNXC:DashboardNaming 2026-06-22-20:08: + The analytics Command Center surface is now labeled Dashboard, so this older projects overview is labeled Project Dashboard to avoid two visible Dashboard destinations. */ const dashboardHeader = ( = { /* 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. */ subtaskBreakdown: "Subtask Breakdown", leftSidebarNav: "Left Sidebar Navigation", - rightDock: "Right Dock Panel", sandbox: "Sandbox (command isolation)", chatRooms: "Chat Rooms", agentOnboarding: "Planning-style Agent Onboarding", @@ -289,10 +288,27 @@ const KNOWN_EXPERIMENTAL_FEATURES: Record = { }; /* -FNXC:Navigation 2026-06-21-00:00: -The dashboard owns the left sidebar and right dock default-on rollout because the shared experimental-feature helper must keep default-off semantics for unrelated experiments. Keep this set local to Settings so toggle checked-state matches App's `leftSidebarNav !== false` and `rightDock !== false` derivations without changing core behavior. +FNXC:SettingsExperimental 2026-06-22-17:55: +Workflow rollout flags remain supported in persisted settings and engine code, but they are no longer normal user-facing Experimental toggles. Hide workflowColumns, workflowGraphExecutor, and dual-observe from the settings list so operators do not accidentally flip the custom-workflow/runtime diagnostic switches from the product UI. + +FNXC:SettingsExperimental 2026-06-22-18:00: +Right Dock Panel is no longer experimental: keep honoring the dock as always-on in App, but hide any stale persisted `rightDock` setting from the Experimental list. */ -const DEFAULT_ON_EXPERIMENTAL_FEATURES = new Set(["leftSidebarNav", "rightDock"]); +const HIDDEN_EXPERIMENTAL_FEATURE_KEYS = new Set([ + "rightDock", + "workflowColumns", + "workflowGraphExecutor", + "workflowInterpreterDualObserve", +]); + +/* +FNXC:Navigation 2026-06-21-00:00: +The dashboard owns the left sidebar default-on rollout because the shared experimental-feature helper must keep default-off semantics for unrelated experiments. Keep this set local to Settings so toggle checked-state matches App's `leftSidebarNav !== false` derivation without changing core behavior. + +FNXC:Navigation 2026-06-22-18:00: +Only Left Sidebar Navigation remains a default-on experimental toggle; right dock was promoted to always-on app chrome and is hidden from this settings surface. +*/ +const DEFAULT_ON_EXPERIMENTAL_FEATURES = new Set(["leftSidebarNav"]); const EXPERIMENTAL_FEATURE_LEGACY_ALIASES: Record = { devServer: "devServerView", @@ -369,6 +385,8 @@ interface SettingsModalProps { onDashboardFontScaleChange?: (scalePct: number) => void; /** Called when shadcn-custom color overrides change */ onShadcnCustomColorsChange?: (colors: Record) => void; + /** Mirrors pending Quick Chat launcher changes into the app shell immediately. */ + onQuickChatButtonModeChange?: (mode: "floating" | "footer" | "off") => void; /** Optional callback when user wants to reopen the onboarding guide */ onReopenOnboarding?: () => void; /** Optional callback to open approvals/mailbox view. */ @@ -619,7 +637,7 @@ export function SettingsModal({ projectId, initialSection, themeMode = "dark", - colorTheme = "default", + colorTheme = "ocean", onThemeModeChange, onColorThemeChange, dashboardFontScalePct = 100, @@ -627,6 +645,7 @@ export function SettingsModal({ resolvedThemeMode, onDashboardFontScaleChange, onShadcnCustomColorsChange, + onQuickChatButtonModeChange, onReopenOnboarding, onOpenApprovals, onOpenWorkflowSettings, @@ -883,6 +902,7 @@ export function SettingsModal({ const initialGlobalMaxConcurrentRef = useRef(4); const hasFetchedGlobalConcurrencyRef = useRef(false); const globalConcurrencyDirtyRef = useRef(false); + const [globalConcurrencyLoaded, setGlobalConcurrencyLoaded] = useState(false); // Import/Export state const [importDialogOpen, setImportDialogOpen] = useState(false); @@ -972,9 +992,11 @@ export function SettingsModal({ } initialGlobalMaxConcurrentRef.current = state.globalMaxConcurrent; hasFetchedGlobalConcurrencyRef.current = true; + setGlobalConcurrencyLoaded(true); }) .catch(() => { // Silently fail — global concurrency may not be available + setGlobalConcurrencyLoaded(true); }); return () => { @@ -2568,6 +2590,7 @@ export function SettingsModal({ projectTrackingRepoOptions={projectTrackingRepoOptions} projectTrackingRepoLoading={projectTrackingRepoLoading} projectTrackingRepoError={projectTrackingRepoError} + onQuickChatButtonModeChange={onQuickChatButtonModeChange} /> ); case "global-general": @@ -2657,6 +2680,7 @@ export function SettingsModal({ form={form} setForm={setForm} globalMaxConcurrent={globalMaxConcurrent} + concurrencyLoading={activeSection === "scheduling" && !globalConcurrencyLoaded && !globalConcurrencyDirtyRef.current} onGlobalMaxConcurrentChange={(value) => { globalConcurrencyDirtyRef.current = true; setGlobalMaxConcurrent(value); @@ -2789,6 +2813,7 @@ export function SettingsModal({ legacyAliases={EXPERIMENTAL_FEATURE_LEGACY_ALIASES} getCanonicalKey={getCanonicalExperimentalFeatureKey} isFeatureEnabled={isDashboardExperimentalFeatureEnabled} + hiddenFeatureKeys={HIDDEN_EXPERIMENTAL_FEATURE_KEYS} /> ); case "backups": diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index 811a61bea9..850e9a51d8 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -1913,6 +1913,9 @@ The footer Actions/Move dropdown buttons sit at the BOTTOM of the embedded panel FNXC:TaskDetailTabs 2026-06-21-00:00: The mobile global `* { touch-action: pan-y; }` lock from FN-6365 prevents horizontal swipe gestures unless each known horizontal scroller opts back into pan-x. The overflowing task-detail tab strip must keep horizontal touch panning enabled so conditional and plugin tabs remain reachable on narrow touch viewports (FN-6864), matching the FN-6450 agent-detail tab precedent. + +FNXC:TaskDetailTabs 2026-06-22-18:00: +Remove the divider between the tab selector and the detail area below. Keep the active-tab underline as the only local selection affordance. */ .detail-tabs { display: flex; @@ -1921,7 +1924,6 @@ The overflowing task-detail tab strip must keep horizontal touch panning enabled touch-action: pan-x pan-y; -webkit-overflow-scrolling: touch; scrollbar-width: thin; - border-bottom: 1px solid var(--border); margin-bottom: var(--space-md); } diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 0900b695e2..94729e1bc4 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -2764,23 +2764,19 @@ export function TaskDetailContent({
- {/* - FNXC:TaskDetail 2026-06-22-18:40: - Board-card full-panel "Back to board" affordance lives here on the far right of the gray header (across from the task id on the left), pushed by margin-left:auto so it never overlaps the id and wraps on narrow widths. Only rendered when embedded AND onBackToBoard are supplied (board-card detail), never in ListView split-pane or modal usages. - */} - {embedded && onBackToBoard && ( + {!isEditing && canEdit && ( )} {/* - FNXC:FloatingWindow 2026-06-22-20:45: - "Pop out" affordance opens this task detail in a movable, resizable, non-blocking FloatingWindow. Rendered whenever onPopOut is wired (List split-pane + board full-panel); App dedupes by task id so re-popping focuses the existing window instead of duplicating. + FNXC:FloatingWindow 2026-06-22-20:45 (updated 2026-06-22-18:32): + "Pop out" affordance opens this task detail in a movable, resizable, non-blocking FloatingWindow. Header action order is edit, then expand/pop-out, then Back to board pinned far right so board-card detail controls read as edit/resize/navigation. */} {onPopOut && ( )} - {!isEditing && canEdit && ( + {/* + FNXC:TaskDetail 2026-06-22-18:40 (updated 2026-06-22-18:32): + Board-card full-panel "Back to board" must be the far-right header action, after edit and expand/pop-out. margin-left:auto pushes it away from the utility controls while keeping it in the same gray header row. Only rendered when embedded AND onBackToBoard are supplied (board-card detail), never in ListView split-pane or modal usages. + */} + {embedded && onBackToBoard && ( )} {embedded && onRequestClose && !onBackToBoard && ( diff --git a/packages/dashboard/app/components/ThemeSelector.tsx b/packages/dashboard/app/components/ThemeSelector.tsx index 58c7f89d46..3d08402a30 100644 --- a/packages/dashboard/app/components/ThemeSelector.tsx +++ b/packages/dashboard/app/components/ThemeSelector.tsx @@ -42,7 +42,7 @@ export function ThemeSelector({ const { t } = useTranslation("app"); const handleReset = useCallback(() => { onThemeModeChange("dark"); - onColorThemeChange("default"); + onColorThemeChange("ocean"); onDashboardFontScaleChange(100); onShadcnCustomColorsChange({}); }, [onThemeModeChange, onColorThemeChange, onDashboardFontScaleChange, onShadcnCustomColorsChange]); diff --git a/packages/dashboard/app/components/TodoView.css b/packages/dashboard/app/components/TodoView.css index 746bc1379e..b2d697ae0d 100644 --- a/packages/dashboard/app/components/TodoView.css +++ b/packages/dashboard/app/components/TodoView.css @@ -404,6 +404,222 @@ The descriptive subtitle renders inside ViewHeader's actions slot; mute it so it } } +/* +FNXC:TodosStyling 2026-06-22-17:35: +Redesign Todos to fit the rest of the dashboard theme: full-height tokenized workspace, surfaced list/detail panes, selected-list metadata, compact modern todo cards, and visible but quiet action bars. Keep the existing two-pane/narrow-stack behavior; this is visual hierarchy and density, not a workflow rewrite. +*/ +.todo-view { + gap: var(--space-md); + padding: var(--space-lg); + background: var(--bg); +} + +.todo-view-layout { + gap: var(--space-md); + padding: 0; +} + +.todo-view-sidebar, +.todo-view-main { + min-height: 0; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: var(--shadow-sm); +} + +.todo-view-sidebar { + width: min(32%, 320px); + min-width: 240px; + padding: var(--space-md); + border-right: 1px solid var(--border); +} + +.todo-view-main { + padding: var(--space-md); +} + +.todo-sidebar-header { + margin-bottom: var(--space-sm); + padding-bottom: var(--space-sm); +} + +.todo-sidebar-title { + color: var(--text); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; +} + +.todo-add-list-btn, +.todo-icon-btn, +.todo-item-reorder-btn { + border-color: transparent; + background: transparent; +} + +.todo-list-items { + gap: var(--space-sm); +} + +.todo-list-item { + min-height: 42px; + padding: var(--space-sm); + border: 1px solid transparent; + border-radius: var(--radius-md); + color: var(--text-muted); +} + +.todo-list-item:hover { + background: var(--card-hover); + border-color: color-mix(in srgb, var(--border) 70%, transparent); +} + +.todo-list-item--active { + background: color-mix(in srgb, var(--todo) 10%, var(--surface)); + border-color: color-mix(in srgb, var(--todo) 32%, var(--border)); + color: var(--text); + box-shadow: inset 3px 0 0 var(--todo); +} + +.todo-list-select-btn { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-sm); +} + +.todo-list-item-name { + font-weight: 600; +} + +.todo-list-item-count { + padding: 2px var(--space-xs); + border: 1px solid var(--border); + border-radius: var(--radius-pill); + background: var(--bg); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.6875rem; + line-height: 1.2; +} + +.todo-list-item--active .todo-list-item-count { + border-color: color-mix(in srgb, var(--todo) 36%, var(--border)); + color: var(--todo); +} + +.todo-items-header { + align-items: flex-start; + margin-bottom: var(--space-md); + padding-bottom: var(--space-sm); + border-bottom: 1px solid var(--border); +} + +.todo-items-heading { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: var(--space-xs); +} + +.todo-items-heading h3 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 1.05rem; + font-weight: 650; +} + +.todo-items-progress { + color: var(--text-muted); + font-size: 0.8125rem; +} + +.todo-add-item-row { + margin-bottom: var(--space-md); + padding: var(--space-sm); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg); +} + +.todo-add-item-row .input { + min-height: 34px; + border-color: transparent; + background: transparent; +} + +.todo-add-item-row .input:focus { + border-color: var(--border); + background: var(--surface); +} + +.todo-add-item-row .btn { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + white-space: nowrap; +} + +.todo-items-list { + gap: var(--space-sm); +} + +.todo-item { + padding: var(--space-md); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--card); + box-shadow: var(--shadow-xs, 0 1px 2px color-mix(in srgb, var(--bg) 70%, transparent)); + transition: background var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast); +} + +.todo-item:hover { + background: var(--surface); + border-color: color-mix(in srgb, var(--todo) 28%, var(--border)); + transform: translateY(-1px); +} + +.todo-item-main-row { + align-items: flex-start; +} + +.todo-item-checkbox { + width: 18px; + height: 18px; + margin-top: 2px; +} + +.todo-item-text { + line-height: 1.45; + font-weight: 500; +} + +.todo-item-text--completed { + color: var(--text-muted); +} + +.todo-item-actions { + align-items: center; + margin-left: calc(var(--space-lg) + var(--space-sm)); + padding-top: var(--space-xs); + border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent); +} + +.todo-item-reorder-btns { + padding-right: var(--space-xs); + border-right: 1px solid var(--border); +} + +.todo-empty-state, +.todo-loading { + border: 1px dashed var(--border); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--surface) 70%, transparent); +} + /* FNXC:TodosStyling 2026-06-22-00:00: NARROW container (right dock): collapse the side-by-side split into a single-panel navigation stack. Exactly one panel shows at a time, full-width with its own internal scroll and no horizontal overflow. `data-mobile-stack-view` (set by the component from `mobileStackView`) decides which panel is visible: "list" shows the master list-selection panel; "detail" shows the items panel with the Back button revealed. Tap targets are enlarged for touch. 520px is tuned to the content: below it the sidebar's fixed width plus the items pane no longer fit comfortably. diff --git a/packages/dashboard/app/components/TodoView.tsx b/packages/dashboard/app/components/TodoView.tsx index 1739789b4f..916962ee7c 100644 --- a/packages/dashboard/app/components/TodoView.tsx +++ b/packages/dashboard/app/components/TodoView.tsx @@ -95,6 +95,22 @@ export function TodoView({ () => sortItems(items.filter((item) => item.listId === selectedListId)), [items, selectedListId], ); + const listItemStats = useMemo(() => { + const stats = new Map(); + for (const list of lists) { + stats.set(list.id, { total: 0, completed: 0 }); + } + for (const item of items) { + const current = stats.get(item.listId) ?? { total: 0, completed: 0 }; + current.total += 1; + if (item.completed) { + current.completed += 1; + } + stats.set(item.listId, current); + } + return stats; + }, [items, lists]); + const selectedListStats = selectedList ? (listItemStats.get(selectedList.id) ?? { total: sortedItems.length, completed: sortedItems.filter((item) => item.completed).length }) : null; function resetListDraftState(): void { setEditingListId(null); @@ -419,6 +435,7 @@ export function TodoView({ {lists.map((list) => { const isActive = list.id === selectedListId; const isEditing = list.id === editingListId; + const stats = listItemStats.get(list.id) ?? { total: 0, completed: 0 }; return (
{list.title} + + {stats.completed}/{stats.total} +
-

{selectedList.title}

+
+

{selectedList.title}

+ {selectedListStats && ( + + {t("todo.completedCount", "{{completed}}/{{total}} complete", { + completed: selectedListStats.completed, + total: selectedListStats.total, + })} + + )} +
@@ -561,6 +591,7 @@ export function TodoView({ void handleAddItem(); }} > + {t("actions.add", "Add")}
diff --git a/packages/dashboard/app/components/ViewHeader.css b/packages/dashboard/app/components/ViewHeader.css index 35410d907a..1e58ede24d 100644 --- a/packages/dashboard/app/components/ViewHeader.css +++ b/packages/dashboard/app/components/ViewHeader.css @@ -3,7 +3,10 @@ FNXC:Navigation 2026-06-22-01:00: Shared main-content view header, modeled after Command Center (.cc-header / .cc-title). Provides the standard --space-lg side/top padding and --space-md bottom gap, an icon + 1.125rem title, and an optional right-aligned actions cluster that wraps below the title on narrow widths so the two never overlap. FNXC:ViewHeader 2026-06-23-03:45: -ViewHeader is now THE canonical top header for every left-sidebar/main-content view. Its defaults match the reference already implemented by Missions (.mission-manager__header--inline) and Agents (.agents-view .view-header): container padding var(--space-lg) var(--space-xl), background var(--surface), a single border-bottom divider, flex-shrink:0, and a --todo-colored leading icon at size 20. The title is 1.125rem/600/var(--text) with a var(--space-sm) icon-title gap. The actions cluster is pushed right with margin-left:auto so refresh/new/filter buttons right-align consistently across views; those buttons should use the shared `btn btn-sm` sizing. Per-view headers must adopt ViewHeader with NO divergent overrides so navigating between any two views shows a pixel-consistent header (same height, icon color/size, title metrics, padding, divider, and button sizing). The Agents scoped override (.agents-view .view-header) is now redundant and removed; these defaults supply that chrome directly. +ViewHeader is now THE canonical top header for every left-sidebar/main-content view. Its defaults match the reference already implemented by Missions (.mission-manager__header--inline) and Agents (.agents-view .view-header): container padding var(--space-lg) var(--space-xl), background var(--surface), no bottom divider, flex-shrink:0, and a --todo-colored leading icon at size 20. The title is 1.125rem/600/var(--text) with a var(--space-sm) icon-title gap. The actions cluster is pushed right with margin-left:auto so refresh/new/filter buttons right-align consistently across views; those buttons should use the shared `btn btn-sm` sizing. Per-view headers must adopt ViewHeader with NO divergent overrides so navigating between any two views shows a pixel-consistent header (same height, icon color/size, title metrics, padding, and button sizing). The Agents scoped override (.agents-view .view-header) is now redundant and removed; these defaults supply that chrome directly. + +FNXC:ViewHeader 2026-06-22-18:00: +All view headers use Missions' surface background without a dividing line after the header. Sidebar section headers follow the same no-post-header-line rule so the app chrome feels seamless across themes. */ /* FNXC:ViewHeader 2026-06-23-04:15: @@ -21,7 +24,6 @@ min-height alone let DESKTOP headers whose actions were TALLER than the canonica min-height: var(--view-header-min-height); padding: var(--space-lg) var(--space-xl); background: var(--surface); - border-bottom: 1px solid var(--border); } .view-header__title { diff --git a/packages/dashboard/app/components/WorkflowSwitcher.css b/packages/dashboard/app/components/WorkflowSwitcher.css index d7ab0c4946..db8b743163 100644 --- a/packages/dashboard/app/components/WorkflowSwitcher.css +++ b/packages/dashboard/app/components/WorkflowSwitcher.css @@ -106,6 +106,41 @@ The board/list workflow dropdown sits beside the project selector in header and line-height: calc(var(--space-md) / var(--space-sm)); } +/* +FNXC:WorkflowSwitcher 2026-06-22-20:30: +Active merges on a workflow should be visible from the dropdown without requiring users to open each board lane. Render a compact pulsing indicator before the counts in both trigger and option rows when that workflow has merging tasks. +*/ +.workflow-switcher-merging-indicator { + display: inline-block; + width: var(--space-sm); + height: var(--space-sm); + border-radius: var(--radius-pill); + background: var(--color-warning); + box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-warning) 45%, transparent); + animation: workflow-switcher-merging-pulse 1.1s ease-in-out infinite; +} + +@keyframes workflow-switcher-merging-pulse { + 0%, 100% { + opacity: 0.45; + transform: scale(0.86); + box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-warning) 40%, transparent); + } + + 50% { + opacity: 1; + transform: scale(1); + box-shadow: 0 0 0 var(--space-xs) color-mix(in srgb, var(--color-warning) 0%, transparent); + } +} + +@media (prefers-reduced-motion: reduce) { + .workflow-switcher-merging-indicator { + animation: none; + opacity: 1; + } +} + /* FNXC:WorkflowSwitcher 2026-06-20-00:00: The switcher's inline Todo, In Progress, and Done count badges intentionally mirror the board column color tokens so each count reads as the same color as the column it summarizes. diff --git a/packages/dashboard/app/components/WorkflowSwitcher.tsx b/packages/dashboard/app/components/WorkflowSwitcher.tsx index 7d6309b254..d4c89b579e 100644 --- a/packages/dashboard/app/components/WorkflowSwitcher.tsx +++ b/packages/dashboard/app/components/WorkflowSwitcher.tsx @@ -26,7 +26,7 @@ interface DropdownPosition { maxHeight: number; } -const ZERO_COUNTS: WorkflowStatusCounts = { todo: 0, inProgress: 0, done: 0 }; +const ZERO_COUNTS: WorkflowStatusCounts = { todo: 0, inProgress: 0, done: 0, merging: 0 }; const DEFAULT_MENU_HORIZONTAL_PADDING = 16; const DEFAULT_MENU_MIN_WIDTH = 240; @@ -85,6 +85,7 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, l const todoLabel = t("workflowSwitcher.todo", "Todo"); const inProgressLabel = t("workflowSwitcher.inProgress", "In Progress"); const doneLabel = t("workflowSwitcher.done", "Done"); + const mergingLabel = t("workflowSwitcher.merging", "Merging"); const editWorkflowLabel = t("workflowSwitcher.editWorkflow", "Edit workflow"); const newWorkflowLabel = t("workflowSwitcher.newWorkflow", "New workflow"); const listboxId = useId(); @@ -270,6 +271,12 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, l const renderCountBadges = (workflowCounts: WorkflowStatusCounts, variant: "trigger" | "option") => (
@@ -572,7 +572,7 @@ export function CommandCenter({ {/* FNXC:CommandCenter 2026-06-22-01:00: Icon size aligned to 20 to match the shared ViewHeader (cc-header is the model for ViewHeader; title is already 1.125rem with --space-lg padding). */}

- {t("commandCenter.heading", "Command Center")} + {t("commandCenter.heading", "Dashboard")}

@@ -580,7 +580,7 @@ export function CommandCenter({
{subViews.map((sub, index) => { const selected = sub.id === activeTab; diff --git a/packages/dashboard/app/components/settings/sections/ExperimentalSection.tsx b/packages/dashboard/app/components/settings/sections/ExperimentalSection.tsx index c4ebb2f900..2c2eb2651d 100644 --- a/packages/dashboard/app/components/settings/sections/ExperimentalSection.tsx +++ b/packages/dashboard/app/components/settings/sections/ExperimentalSection.tsx @@ -11,14 +11,16 @@ export interface ExperimentalSectionProps extends SectionBaseProps { getCanonicalKey: (key: string) => string; /** Whether a feature is enabled, honoring legacy aliases. */ isFeatureEnabled: (features: Record, key: string) => boolean; + /** Feature keys that are supported internally but should not render as user toggles. */ + hiddenFeatureKeys?: ReadonlySet; } -export function ExperimentalSection({ scopeBanner, form, setForm, knownFeatures, legacyAliases, getCanonicalKey, isFeatureEnabled, }: ExperimentalSectionProps) { +export function ExperimentalSection({ scopeBanner, form, setForm, knownFeatures, legacyAliases, getCanonicalKey, isFeatureEnabled, hiddenFeatureKeys, }: ExperimentalSectionProps) { const { t } = useTranslation("app"); const experimentalFeatures = form.experimentalFeatures ?? {}; const allFeatureKeys = Array.from(new Set([ ...Object.keys(knownFeatures), ...Object.keys(experimentalFeatures).map(getCanonicalKey), - ])).sort((a, b) => a.localeCompare(b)); + ])).filter((key) => !hiddenFeatureKeys?.has(key)).sort((a, b) => a.localeCompare(b)); const featureFlags = allFeatureKeys.map((key) => [key, isFeatureEnabled(experimentalFeatures, key)] as const); return (<> {scopeBanner} diff --git a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx index 0c8a5296db..51cfdca7d0 100644 --- a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx @@ -15,8 +15,9 @@ export interface GeneralSectionProps extends SectionBaseProps { projectTrackingRepoOptions: TrackingRepoOption[]; projectTrackingRepoLoading: boolean; projectTrackingRepoError: string | null; + onQuickChatButtonModeChange?: (mode: "floating" | "footer" | "off") => void; } -export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast, prefixError, setPrefixError, projectTrackingRepoOptions, projectTrackingRepoLoading, projectTrackingRepoError, }: GeneralSectionProps) { +export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast, prefixError, setPrefixError, projectTrackingRepoOptions, projectTrackingRepoLoading, projectTrackingRepoError, onQuickChatButtonModeChange, }: GeneralSectionProps) { const { t } = useTranslation("app"); const [builtinWorkflows, setBuiltinWorkflows] = useState([]); useEffect(() => { @@ -109,6 +110,7 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast { + { const val = e.target.value; onGlobalMaxConcurrentChange(val === "" ? undefined : Number(val)); }}/> @@ -31,14 +36,14 @@ export function SchedulingSection({ scopeBanner, form, setForm, globalMaxConcurr
- { + { const val = e.target.value; setForm((f) => ({ ...f, maxConcurrent: val === "" ? undefined : Number(val) } as SettingsFormState)); }}/>
- { + { const val = e.target.value; setForm((f) => ({ ...f, maxTriageConcurrent: val === "" ? undefined : Number(val) } as SettingsFormState)); }}/> diff --git a/packages/dashboard/app/components/themeOptions.ts b/packages/dashboard/app/components/themeOptions.ts index 9a197ce755..ad57eba548 100644 --- a/packages/dashboard/app/components/themeOptions.ts +++ b/packages/dashboard/app/components/themeOptions.ts @@ -4,6 +4,9 @@ import type { ColorTheme, ThemeMode } from "@fusion/core"; /* FNXC:Theme 2026-06-19-12:00: The Settings theme grid and Command Center theme dropdown must share one source of truth for theme labels and swatch classes so color-chip affordances stay synchronized across both theme selectors. + +FNXC:DashboardTheming 2026-06-22-18:36: +Ocean is the default theme label for new/unset users. The historical "default" id remains selectable as Fusion Legacy so users who already chose default are not silently moved to Ocean. */ export const THEME_MODES: { value: ThemeMode; label: string; icon: LucideIcon }[] = [ { value: "light", label: "Light", icon: Sun }, @@ -12,8 +15,8 @@ export const THEME_MODES: { value: ThemeMode; label: string; icon: LucideIcon }[ ]; export const COLOR_THEMES: { value: ColorTheme; label: string; className: string }[] = [ - { value: "default", label: "Default", className: "theme-swatch-default" }, - { value: "ocean", label: "Ocean", className: "theme-swatch-ocean" }, + { value: "default", label: "Fusion Legacy", className: "theme-swatch-default" }, + { value: "ocean", label: "Ocean (Default)", className: "theme-swatch-ocean" }, { value: "forest", label: "Forest", className: "theme-swatch-forest" }, { value: "sunset", label: "Sunset", className: "theme-swatch-sunset" }, { value: "zen", label: "Zen", className: "theme-swatch-zen" }, diff --git a/packages/dashboard/app/components/workflowStatusCounts.ts b/packages/dashboard/app/components/workflowStatusCounts.ts index 6da167e125..6d75ff3494 100644 --- a/packages/dashboard/app/components/workflowStatusCounts.ts +++ b/packages/dashboard/app/components/workflowStatusCounts.ts @@ -5,16 +5,20 @@ export interface WorkflowStatusCounts { todo: number; inProgress: number; done: number; + merging: number; } const EMPTY_COUNTS = (): WorkflowStatusCounts => ({ todo: 0, inProgress: 0, done: 0, + merging: 0, }); type WorkflowStatusBucket = keyof WorkflowStatusCounts | "excluded"; +const MERGING_STATUSES = new Set(["merging", "merging-pr", "merging-fix"]); + /** * FNXC:WorkflowSwitcher 2026-06-20-00:09: * The board/list workflow dropdown must show compact Todo, In Progress, and Done task counts for every selectable workflow without duplicating logic across render surfaces. @@ -83,6 +87,13 @@ export function computeWorkflowStatusCounts( const counts = countsByWorkflow.get(workflow.id) ?? EMPTY_COUNTS(); counts[bucket] += 1; + if (MERGING_STATUSES.has(task.status ?? "")) { + /* + FNXC:WorkflowSwitcher 2026-06-22-20:30: + Workflow boards need a visible flashing indicator in the workflow dropdown when any task assigned to that workflow is actively merging, independent of whether the workflow's review/merge column buckets as Todo or In Progress. + */ + counts.merging += 1; + } countsByWorkflow.set(workflow.id, counts); } diff --git a/packages/dashboard/app/hooks/__tests__/useTheme.test.ts b/packages/dashboard/app/hooks/__tests__/useTheme.test.ts index d2d86b7edd..b8460a94d3 100644 --- a/packages/dashboard/app/hooks/__tests__/useTheme.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTheme.test.ts @@ -104,7 +104,7 @@ describe("useTheme", () => { const { result } = renderHook(() => useTheme()); expect(result.current.themeMode).toBe("dark"); - expect(result.current.colorTheme).toBe("default"); + expect(result.current.colorTheme).toBe("ocean"); }); it("initializes from localStorage", () => { @@ -117,6 +117,14 @@ describe("useTheme", () => { expect(result.current.colorTheme).toBe("ocean"); }); + it("preserves explicit legacy default color theme from localStorage", () => { + localStorageMock[COLOR_THEME_STORAGE_KEY] = "default"; + + const { result } = renderHook(() => useTheme()); + + expect(result.current.colorTheme).toBe("default"); + }); + it("hydrates themeMode from backend on mount", async () => { mockFetchGlobalSettings.mockResolvedValue({ themeMode: "light" }); @@ -131,16 +139,16 @@ describe("useTheme", () => { }); it("hydrates colorTheme from backend on mount", async () => { - mockFetchGlobalSettings.mockResolvedValue({ colorTheme: "ocean" }); + mockFetchGlobalSettings.mockResolvedValue({ colorTheme: "forest" }); const { result } = renderHook(() => useTheme()); - expect(result.current.colorTheme).toBe("default"); + expect(result.current.colorTheme).toBe("ocean"); await waitFor(() => { - expect(result.current.colorTheme).toBe("ocean"); + expect(result.current.colorTheme).toBe("forest"); }); - expect(localStorageMock[COLOR_THEME_STORAGE_KEY]).toBe("ocean"); + expect(localStorageMock[COLOR_THEME_STORAGE_KEY]).toBe("forest"); }); it("hydrates dashboard font scale from backend on mount", async () => { @@ -761,7 +769,7 @@ describe("useTheme", () => { const { result } = renderHook(() => useTheme()); - expect(result.current.colorTheme).toBe("default"); + expect(result.current.colorTheme).toBe("ocean"); }); it("clamps invalid dashboard font scale values from localStorage", () => { @@ -789,7 +797,7 @@ describe("useTheme", () => { const { result } = renderHook(() => useTheme()); expect(result.current.themeMode).toBe("dark"); - expect(result.current.colorTheme).toBe("default"); + expect(result.current.colorTheme).toBe("ocean"); }); describe("dynamic theme-data.css loading", () => { @@ -873,7 +881,7 @@ describe("getThemeInitScript", () => { }); expect(script).toContain("validThemes"); expect(script).toContain("if (colorTheme === 'shadcn-mono') colorTheme = 'shadcn-mono-red';"); - expect(script).toContain("colorTheme = 'default'"); + expect(script).toContain("colorTheme = 'ocean'"); }); it("keeps index.html inline theme validation in sync with supported themes", () => { diff --git a/packages/dashboard/app/hooks/useAppSettings.ts b/packages/dashboard/app/hooks/useAppSettings.ts index 17b0b6ac3b..b88e06f42e 100644 --- a/packages/dashboard/app/hooks/useAppSettings.ts +++ b/packages/dashboard/app/hooks/useAppSettings.ts @@ -35,6 +35,7 @@ export interface UseAppSettingsResult { toggleGlobalPause: () => Promise; toggleEnginePause: () => Promise; toggleShowQuickChatFAB: () => Promise; + setQuickChatButtonModeImmediate: (mode: QuickChatButtonMode) => void; toggleAutoReloadOnVersionChange: () => Promise; /** Re-fetches settings from the backend to pick up changes made externally (e.g., by SettingsModal). */ refresh: () => Promise; @@ -195,6 +196,15 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { } }, [showQuickChatFAB, projectId]); + const setQuickChatButtonModeImmediate = useCallback((mode: QuickChatButtonMode) => { + /* + FNXC:QuickChat 2026-06-22-18:55: + The Quick Chat launcher setting must move the visible launcher immediately between floating FAB, footer button, and off while Settings is still open. Persistence still flows through SettingsModal save; this mirrors the pending selection in the app shell. + */ + setQuickChatButtonMode(mode); + setShowQuickChatFAB(mode === "floating"); + }, []); + const toggleAutoReloadOnVersionChange = useCallback(async () => { const next = !autoReloadOnVersionChange; setAutoReloadOnVersionChangeState(next); @@ -236,6 +246,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { toggleGlobalPause, toggleEnginePause, toggleShowQuickChatFAB, + setQuickChatButtonModeImmediate, toggleAutoReloadOnVersionChange, refresh, }; diff --git a/packages/dashboard/app/hooks/useTheme.ts b/packages/dashboard/app/hooks/useTheme.ts index 52fec316a3..aad8683b28 100644 --- a/packages/dashboard/app/hooks/useTheme.ts +++ b/packages/dashboard/app/hooks/useTheme.ts @@ -16,6 +16,7 @@ const DEFAULT_FONT_SCALE_PCT = 100; const MIN_FONT_SCALE_PCT = 85; const MAX_FONT_SCALE_PCT = 125; const VALID_COLOR_THEMES = [...COLOR_THEMES] satisfies ColorTheme[]; +const DEFAULT_COLOR_THEME: ColorTheme = "ocean"; const THEME_DATA_ID = "theme-data"; const THEME_DATA_FILENAME = "theme-data.css"; @@ -83,7 +84,7 @@ function readCachedThemeMode(): ThemeMode { } function readCachedColorTheme(): ColorTheme { - if (!isBrowser) return "default"; + if (!isBrowser) return DEFAULT_COLOR_THEME; try { let colorTheme = localStorage.getItem(COLOR_THEME_STORAGE_KEY); // FNXC:DashboardTheming 2026-06-20-00:00: FN-6813 keeps existing shadcn-mono users on the renamed red mono variant before the validity guard would otherwise fall back to default. @@ -94,7 +95,11 @@ function readCachedColorTheme(): ColorTheme { } catch { // localStorage not available, use default } - return "default"; + /* + FNXC:DashboardTheming 2026-06-22-18:36: + Missing/invalid cached theme resolves to Ocean for new installs, but an explicit cached "default" remains valid above and stays on Fusion Legacy. + */ + return DEFAULT_COLOR_THEME; } function writeCachedThemeMode(mode: ThemeMode): void { @@ -460,12 +465,13 @@ export function getThemeInitScript(): string { (function() { try { var mode = localStorage.getItem('${THEME_MODE_STORAGE_KEY}') || 'dark'; - var colorTheme = localStorage.getItem('${COLOR_THEME_STORAGE_KEY}') || 'default'; + var colorTheme = localStorage.getItem('${COLOR_THEME_STORAGE_KEY}') || '${DEFAULT_COLOR_THEME}'; var validThemes = ${JSON.stringify(VALID_COLOR_THEMES)}; + // FNXC:DashboardTheming 2026-06-22-18:36: Unset startup theme is Ocean; an explicit stored "default" remains the Fusion Legacy theme and must not be migrated. // FNXC:DashboardTheming 2026-06-20-00:00: FN-6813 remaps the legacy mono id before bootstrap validation so persisted users keep the red mono accent. if (colorTheme === 'shadcn-mono') colorTheme = 'shadcn-mono-red'; if (!validThemes.includes(colorTheme)) { - colorTheme = 'default'; + colorTheme = '${DEFAULT_COLOR_THEME}'; } var fontScale = Number(localStorage.getItem('${FONT_SCALE_STORAGE_KEY}') || '${DEFAULT_FONT_SCALE_PCT}'); if (!Number.isFinite(fontScale)) { @@ -521,7 +527,7 @@ export function getThemeInitScript(): string { } } catch (e) { document.documentElement.setAttribute('data-theme', 'dark'); - document.documentElement.setAttribute('data-color-theme', 'default'); + document.documentElement.setAttribute('data-color-theme', '${DEFAULT_COLOR_THEME}'); document.documentElement.style.fontSize = '${DEFAULT_FONT_SCALE_PCT}%'; } })(); diff --git a/packages/dashboard/app/index.html b/packages/dashboard/app/index.html index e8fae1da9f..776bb45536 100644 --- a/packages/dashboard/app/index.html +++ b/packages/dashboard/app/index.html @@ -159,12 +159,13 @@ (function() { try { var mode = localStorage.getItem('kb-dashboard-theme-mode') || 'dark'; - var colorTheme = localStorage.getItem('kb-dashboard-color-theme') || 'default'; + var colorTheme = localStorage.getItem('kb-dashboard-color-theme') || 'ocean'; var validThemes = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'slate', 'ash', 'air', 'graphite', 'silver', 'solarized', 'factory', 'factory-mono', 'ayu', 'one-dark', 'nord', 'dracula', 'gruvbox', 'tokyo-night', 'catppuccin-mocha', 'github-dark', 'everforest', 'rose-pine', 'kanagawa', 'night-owl', 'palenight', 'monokai-pro', 'slime', 'brutalist', 'neon-city', 'parchment', 'terminal', 'glass', 'horizon', 'vitesse', 'outrun', 'snazzy', 'porple', 'espresso', 'mars', 'poimandres', 'ember', 'rust', 'copper', 'foundry', 'carbon', 'sandstone', 'lagoon', 'frost', 'lavender', 'neon-bloom', 'sepia', 'shadcn', 'shadcn-custom', 'shadcn-blue', 'shadcn-green', 'shadcn-red', 'shadcn-purple', 'shadcn-pink', 'shadcn-orange', 'shadcn-yellow', 'shadcn-mono-red', 'shadcn-mono-blue', 'shadcn-mono-green', 'shadcn-mono-purple', 'shadcn-mono-pink', 'shadcn-mono-orange', 'shadcn-mono-yellow', 'shadcn-black', 'shadcn-gray', 'shadcn-gray-blue']; + // FNXC:DashboardTheming 2026-06-22-18:36: Unset startup theme is Ocean; an explicit stored "default" remains Fusion Legacy and must not be migrated. // FNXC:DashboardTheming 2026-06-20-00:00: FN-6813 remaps the legacy mono id before pre-hydration validation so persisted users keep the red mono accent. if (colorTheme === 'shadcn-mono') colorTheme = 'shadcn-mono-red'; if (!validThemes.includes(colorTheme)) { - colorTheme = 'default'; + colorTheme = 'ocean'; } var fontScale = Number(localStorage.getItem('kb-dashboard-font-scale-pct') || '100'); if (!Number.isFinite(fontScale)) { @@ -219,7 +220,7 @@ } } catch (e) { document.documentElement.setAttribute('data-theme', 'dark'); - document.documentElement.setAttribute('data-color-theme', 'default'); + document.documentElement.setAttribute('data-color-theme', 'ocean'); document.documentElement.style.fontSize = '100%'; } })(); diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css index a8fd4b417b..4aee476c19 100644 --- a/packages/dashboard/app/styles.css +++ b/packages/dashboard/app/styles.css @@ -150,7 +150,8 @@ html { /* FNXC:ViewHeader 2026-06-23-04:15: Canonical main-content view-header height. Headers with btn-sm action buttons render taller (~61px border-box) than title-only headers (~54px), so every canonical header (ViewHeader, Missions, embedded Planning, Goals, Automations, Import Tasks) pins this min-height to stay pixel-identical regardless of whether actions are present. - Derivation (border-box): vertical padding var(--space-lg)*2 = 32px + 1px bottom divider + ~28px tallest content row (btn-sm: 4+4 padding + 1+1 border + ~18px 12px-font line) = 61px. + FNXC:ViewHeader 2026-06-22-18:00: + The 1px reserve remains after removing header dividers so existing header height stays stable while the visible line disappears. */ --view-header-min-height: calc(var(--space-lg) * 2 + 28px + 1px); /* @@ -1244,8 +1245,7 @@ body { justify-content: space-between; align-items: center; padding: var(--modal-padding); - border-bottom: 1px solid var(--border); - background: color-mix(in srgb, var(--text) 10%, transparent); + background: var(--surface); } .modal-header h3 { font-size: 15px; @@ -1706,7 +1706,6 @@ input[type="range"]:focus-visible { padding: var(--space-lg) 0 var(--space-md); margin: 0; color: var(--text); - border-bottom: 1px solid var(--border); margin-bottom: var(--space-xs); } diff --git a/packages/dashboard/app/utils/__tests__/boardScrollSnapshot.test.ts b/packages/dashboard/app/utils/__tests__/boardScrollSnapshot.test.ts new file mode 100644 index 0000000000..21a4ad982f --- /dev/null +++ b/packages/dashboard/app/utils/__tests__/boardScrollSnapshot.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { captureBoardScrollSnapshot, restoreBoardScrollSnapshot } from "../boardScrollSnapshot"; + +describe("boardScrollSnapshot", () => { + it("round-trips board horizontal scroll and per-column vertical scroll", () => { + document.body.innerHTML = ` +
+
+
+
+ `; + const board = document.getElementById("board") as HTMLElement; + const todoBody = document.querySelector('[data-column="todo"] .column-body') as HTMLElement; + const activeBody = document.querySelector('[data-column="in-progress"] .column-body') as HTMLElement; + + board.scrollLeft = 240; + board.scrollTop = 12; + todoBody.scrollTop = 380; + activeBody.scrollTop = 95; + + const snapshot = captureBoardScrollSnapshot(); + + board.scrollLeft = 0; + board.scrollTop = 0; + todoBody.scrollTop = 0; + activeBody.scrollTop = 0; + + expect(restoreBoardScrollSnapshot(snapshot)).toBe(true); + expect(board.scrollLeft).toBe(240); + expect(board.scrollTop).toBe(12); + expect(todoBody.scrollTop).toBe(380); + expect(activeBody.scrollTop).toBe(95); + }); + + it("returns false when the board is not mounted", () => { + document.body.innerHTML = ""; + + expect(captureBoardScrollSnapshot()).toBeNull(); + expect(restoreBoardScrollSnapshot({ boardLeft: 10, boardTop: 0, columnTops: {} })).toBe(false); + }); +}); diff --git a/packages/dashboard/app/utils/boardScrollSnapshot.ts b/packages/dashboard/app/utils/boardScrollSnapshot.ts new file mode 100644 index 0000000000..e91cf7b07d --- /dev/null +++ b/packages/dashboard/app/utils/boardScrollSnapshot.ts @@ -0,0 +1,54 @@ +export interface BoardScrollSnapshot { + boardLeft: number; + boardTop: number; + columnTops: Record; +} + +function getBoardDocument(doc?: Document): Document | null { + if (doc) return doc; + return typeof document === "undefined" ? null : document; +} + +/* +FNXC:BoardNavigation 2026-06-22-20:15: +Board-card task detail replaces the board instead of overlaying it. Capture horizontal board scroll and per-column vertical scroll before opening detail, then restore after Back to board remounts the board so users return to the same lane/card context. +*/ +export function captureBoardScrollSnapshot(doc?: Document): BoardScrollSnapshot | null { + const ownerDocument = getBoardDocument(doc); + const board = ownerDocument?.getElementById("board") as HTMLElement | null; + if (!board) return null; + + const columnTops: Record = {}; + board.querySelectorAll(".column[data-column]").forEach((column) => { + const columnId = column.dataset.column; + const body = column.querySelector(".column-body"); + if (columnId && body) { + columnTops[columnId] = body.scrollTop; + } + }); + + return { + boardLeft: board.scrollLeft, + boardTop: board.scrollTop, + columnTops, + }; +} + +export function restoreBoardScrollSnapshot(snapshot: BoardScrollSnapshot | null, doc?: Document): boolean { + if (!snapshot) return false; + const ownerDocument = getBoardDocument(doc); + const board = ownerDocument?.getElementById("board") as HTMLElement | null; + if (!board) return false; + + board.scrollLeft = snapshot.boardLeft; + board.scrollTop = snapshot.boardTop; + board.querySelectorAll(".column[data-column]").forEach((column) => { + const columnId = column.dataset.column; + const body = column.querySelector(".column-body"); + if (columnId && body && Object.prototype.hasOwnProperty.call(snapshot.columnTops, columnId)) { + body.scrollTop = snapshot.columnTops[columnId]; + } + }); + + return true; +} diff --git a/packages/engine/src/__tests__/executor-test-helpers.ts b/packages/engine/src/__tests__/executor-test-helpers.ts index f1bb3093ba..641277b269 100644 --- a/packages/engine/src/__tests__/executor-test-helpers.ts +++ b/packages/engine/src/__tests__/executor-test-helpers.ts @@ -313,6 +313,23 @@ export const mockedInstallTaskWorktreeIdentityGuard = vi.mocked(installTaskWorkt export type EventListener = (...args: unknown[]) => void; +const withLegacyWorkflowFeatureDefaults = (settings: Record) => ({ + ...settings, + experimentalFeatures: { + workflowColumns: false, + workflowGraphExecutor: false, + ...((settings.experimentalFeatures as Record | undefined) ?? {}), + }, +}); + +const createLegacySettingsMock = (initialSettings: Record) => { + const mock = vi.fn().mockResolvedValue(withLegacyWorkflowFeatureDefaults(initialSettings)); + const mockResolvedValue = mock.mockResolvedValue.bind(mock); + mock.mockResolvedValue = ((settings: Record) => + mockResolvedValue(withLegacyWorkflowFeatureDefaults(settings))) as typeof mock.mockResolvedValue; + return mock; +}; + export function createMockStore() { const listeners = new Map(); const store = { @@ -370,7 +387,7 @@ export function createMockStore() { parseStepsFromPrompt: vi.fn().mockResolvedValue([]), parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), updateSettings: vi.fn().mockResolvedValue({}), - getSettings: vi.fn().mockResolvedValue({ + getSettings: createLegacySettingsMock({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, diff --git a/packages/engine/src/__tests__/scheduler-node-routing.test.ts b/packages/engine/src/__tests__/scheduler-node-routing.test.ts index 108eea1b74..c4b3d68fd1 100644 --- a/packages/engine/src/__tests__/scheduler-node-routing.test.ts +++ b/packages/engine/src/__tests__/scheduler-node-routing.test.ts @@ -50,10 +50,19 @@ function createMockTask(overrides: Partial = {}): Task { } as Task; } +const withLegacyWorkflowGraphDefault = (settings: Record) => ({ + ...settings, + experimentalFeatures: { + workflowColumns: false, + workflowGraphExecutor: false, + ...((settings.experimentalFeatures as Record | undefined) ?? {}), + }, +}); + function createMockStore(task: Task, settings: Record = {}): TaskStore { return { listTasks: vi.fn().mockResolvedValue([task]), - getSettings: vi.fn().mockResolvedValue(settings), + getSettings: vi.fn().mockResolvedValue(withLegacyWorkflowGraphDefault(settings)), getTask: vi.fn().mockResolvedValue(task), updateTask: vi.fn().mockResolvedValue(undefined), moveTask: vi.fn().mockResolvedValue(undefined), diff --git a/packages/engine/src/__tests__/scheduler.test.ts b/packages/engine/src/__tests__/scheduler.test.ts index b4ada411cd..f892174918 100644 --- a/packages/engine/src/__tests__/scheduler.test.ts +++ b/packages/engine/src/__tests__/scheduler.test.ts @@ -93,8 +93,18 @@ function createMockTask(overrides: Partial = {}): Task { } // Mock store factory +const withLegacyWorkflowGraphDefault = (settings: Record) => ({ + ...settings, + experimentalFeatures: { + workflowColumns: false, + workflowGraphExecutor: false, + ...((settings.experimentalFeatures as Record | undefined) ?? {}), + }, +}); + function createMockStore(overrides: Partial = {}): TaskStore { - return { + const overrideGetSettings = overrides.getSettings; + const store = { listTasks: vi.fn().mockResolvedValue([]), getSettings: vi.fn().mockResolvedValue({}), getTask: vi.fn().mockResolvedValue(createMockTask()), @@ -114,7 +124,14 @@ function createMockStore(overrides: Partial = {}): TaskStore { on: vi.fn(), off: vi.fn(), ...overrides, - } as unknown as TaskStore; + }; + store.getSettings = vi.fn(async (...args: unknown[]) => { + const settings = overrideGetSettings === undefined + ? {} + : await (overrideGetSettings as (...args: unknown[]) => Promise>)(...args); + return withLegacyWorkflowGraphDefault(settings); + }) as typeof store.getSettings; + return store as unknown as TaskStore; } async function flushAsyncWork(): Promise { diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 9d474584a8..09c8f3029a 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -4126,9 +4126,12 @@ export class TaskExecutor { }); return true; } - const hasWorkflowResolver = typeof this.store.getTaskWorkflowSelection === "function"; const explicitlyEnabled = isExperimentalFeatureEnabled(settings, "workflowGraphExecutor"); - if (!hasWorkflowResolver && !explicitlyEnabled) return false; + /* + FNXC:WorkflowExecution 2026-06-22-18:00: + workflowGraphExecutor is default-on, but explicit false remains the runtime kill switch for legacy executor paths and tests that verify legacy executor behavior. + */ + if (!explicitlyEnabled) return false; settings = { ...settings, experimentalFeatures: { diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 92b4b62bfe..49070510a9 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -1582,7 +1582,7 @@ "trendTitle": "Ecosystem trend", "uniqueModels": "Active models" }, - "empty": "No usage data yet. Run some agents to populate the Command Center.", + "empty": "No usage data yet. Run some agents to populate the Dashboard.", "funnel": { "ariaLabel": "Tasks per workflow stage", "completionRate": "Completion rate", @@ -1628,7 +1628,7 @@ "totalsTitle": "GitHub issue flow" }, "heading": "Dashboard", - "loading": "Loading command center...", + "loading": "Loading dashboard...", "missionControl": { "activeNodes": "Active nodes", "activeRuns": "Active runs", @@ -1916,6 +1916,7 @@ "initializingDashboard": "Initializing dashboard...", "loadingMessage": "Loading Fusion dashboard", "loadingProgress": "Dashboard loading progress", + "title": "Project Dashboard", "updatingMessage": "Updating Fusion dashboard", "updatingVersion": "Updating to a new frontend version..." }, @@ -8508,10 +8509,12 @@ "widgetDefault": "Default" }, "workflowSwitcher": { - "countsAria": "{{todoLabel}}: {{todo}}, {{inProgressLabel}}: {{inProgress}}, {{doneLabel}}: {{done}}", + "countsAria": "{{todoLabel}}: {{todo}}, {{inProgressLabel}}: {{inProgress}}, {{doneLabel}}: {{done}}{{mergingSuffix}}", "done": "Done", "inProgress": "In Progress", "label": "Workflow", + "merging": "Merging", + "mergingTitle": "{{count}} merging task{{plural}}", "todo": "Todo", "triggerAria": "Select workflow. Current workflow: {{name}}", "editWorkflow": "Edit workflow", diff --git a/plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.css b/plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.css index 76ef2fb883..8f017e56f0 100644 --- a/plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.css +++ b/plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.css @@ -2,19 +2,68 @@ /* FNXC:RoadmapStyling 2026-06-21-00:00: Roadmap surfaces must use defined dashboard tokens (--card, --surface, --text). Retired elevated-surface, input-surface, and primary-text aliases rendered surfaces transparent and text uncolored (FN-6867). A CSS-token guard prevents reintroduction. */ .roadmaps-view { display: flex; + flex-direction: column; height: 100%; overflow: hidden; + background: var(--bg); } .roadmaps-view--loading, .roadmaps-view--error { display: flex; +} + +.roadmaps-view__top-header { + box-sizing: border-box; + display: flex; + flex: 0 0 auto; align-items: center; - justify-content: center; + min-height: var(--view-header-min-height, 61px); + padding: var(--space-lg) var(--space-xl); + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.roadmaps-view__top-title { + display: flex; + align-items: center; + gap: var(--space-sm); + min-width: 0; + margin: 0; + color: var(--text); + font-size: 1.125rem; + font-weight: 600; +} + +.roadmaps-view__top-title svg { + flex-shrink: 0; + color: var(--todo); +} + +.roadmaps-view__top-title span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.roadmaps-view__body { + display: flex; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + padding: var(--space-lg); + gap: var(--space-lg); + overflow: hidden; } .roadmaps-view__loading-state, .roadmaps-view__error-state { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + flex-direction: column; text-align: center; color: var(--text-muted); } @@ -76,8 +125,10 @@ flex-shrink: 0; display: flex; flex-direction: column; - border-right: 1px solid var(--border); + border: 1px solid var(--border); + border-radius: var(--radius-lg); background: var(--card); + box-shadow: var(--shadow-sm); } .roadmaps-view__sidebar-header { @@ -220,6 +271,10 @@ flex-direction: column; min-width: 0; overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: var(--shadow-sm); } .roadmaps-view__empty-main { diff --git a/plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.tsx b/plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.tsx index 35de144fc9..3ebfdab5e5 100644 --- a/plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.tsx +++ b/plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, useEffect, useRef } from "react"; -import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles, Download, Copy, Loader, ArrowLeft, ChevronUp } from "lucide-react"; +import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles, Download, Copy, Loader, ArrowLeft, ChevronUp, Map } from "lucide-react"; import "./RoadmapsView.css"; import type { ToastType } from "./types.js"; import { useRoadmaps, type FeatureSuggestion, type MilestoneSuggestion, type SuggestionDraftPatch } from "./useRoadmaps.js"; @@ -2108,6 +2108,12 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) { if (loading && roadmaps.length === 0) { return (
+
+

+ + Roadmaps +

+
Loading roadmaps...
); @@ -2116,6 +2122,12 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) { if (error && roadmaps.length === 0) { return (
+
+

+ + Roadmaps +

+

Failed to load roadmaps

{error.message}

@@ -2126,40 +2138,51 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) { return (
- {/* Mobile Roadmap List (shown when mobile and no roadmap selected) */} - {isMobile && !effectiveSelectedRoadmapId && ( - selectRoadmap(id)} - onCreate={() => setMobileShowCreateForm(true)} - onEdit={handleStartRoadmapEdit} - onDelete={handleDeleteRoadmap} - onExport={(roadmap) => handleOpenHandoffModal(roadmap.id, roadmap.title)} - showCreateForm={mobileShowCreateForm} - onCancelCreate={() => setMobileShowCreateForm(false)} - onSaveCreate={async (input) => { - await handleCreateRoadmap(input); - setMobileShowCreateForm(false); - }} - /> - )} + {/* + FNXC:Roadmaps 2026-06-22-18:00: + Plugin Roadmaps needs the same top chrome as built-in dashboard views: full-width surface header, todo-tinted icon, canonical padding, and a divider before the scrollable body. The internal roadmap sidebar stays below this header so Roadmaps aligns with Artifacts/Skills/Missions while preserving its own list/detail workflow. + */} +
+

+ + Roadmaps +

+
+
+ {/* Mobile Roadmap List (shown when mobile and no roadmap selected) */} + {isMobile && !effectiveSelectedRoadmapId && ( + selectRoadmap(id)} + onCreate={() => setMobileShowCreateForm(true)} + onEdit={handleStartRoadmapEdit} + onDelete={handleDeleteRoadmap} + onExport={(roadmap) => handleOpenHandoffModal(roadmap.id, roadmap.title)} + showCreateForm={mobileShowCreateForm} + onCancelCreate={() => setMobileShowCreateForm(false)} + onSaveCreate={async (input) => { + await handleCreateRoadmap(input); + setMobileShowCreateForm(false); + }} + /> + )} - {/* Desktop sidebar (hidden on mobile) */} - {!isMobile && ( - + )} - {/* Main content */} -
+ {/* Main content */} +
{/* Mobile header when roadmap is selected */} {isMobile && effectiveSelectedRoadmapId && ( )} -
+
+
{/* Feature create form overlay */} {createForm.type === "feature" && createForm.parentId && ( diff --git a/plugins/fusion-plugin-roadmap/src/dashboard/__tests__/RoadmapsView.test.tsx b/plugins/fusion-plugin-roadmap/src/dashboard/__tests__/RoadmapsView.test.tsx index 1be6ce0d1b..809872d005 100644 --- a/plugins/fusion-plugin-roadmap/src/dashboard/__tests__/RoadmapsView.test.tsx +++ b/plugins/fusion-plugin-roadmap/src/dashboard/__tests__/RoadmapsView.test.tsx @@ -55,6 +55,7 @@ vi.mock("lucide-react", () => ({ ArrowLeft: (props: Record) => ArrowLeft, ChevronLeft: (props: Record) => ChevronLeft, ChevronUp: (props: Record) => ChevronUp, + Map: (props: Record) => Map, })); // Viewport mode mock helper @@ -142,7 +143,7 @@ describe("RoadmapsView", () => { render(); await waitFor(() => { - expect(screen.getByText("Roadmaps")).toBeInTheDocument(); + expect(screen.getAllByText("Roadmaps").length).toBeGreaterThanOrEqual(2); expect(screen.getByText("Q2 Roadmap")).toBeInTheDocument(); }); @@ -170,7 +171,7 @@ describe("RoadmapsView", () => { expect(screen.getByText("Q2 Roadmap")).toBeInTheDocument(); }); expect(screen.getByText("Q3 Roadmap")).toBeInTheDocument(); - expect(screen.getByText("Roadmaps")).toBeInTheDocument(); + expect(screen.getAllByText("Roadmaps").length).toBeGreaterThanOrEqual(2); }); it("shows empty state when no roadmaps exist", async () => {