fix(dashboard): polish app chrome and workflow defaults
This commit is contained in:
5
.changeset/polish-dashboard-theme-roadmaps.md
Normal file
5
.changeset/polish-dashboard-theme-roadmaps.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Polish dashboard navigation, themes, roadmaps, chat, and task-detail header behavior.
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, boolean> });
|
||||
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<string, unknown>).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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,21 +4,31 @@ const LEGACY_EXPERIMENTAL_FEATURE_ALIASES: Record<string, string> = {
|
||||
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<Settings, "experimentalFeatures"> | 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;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,11 @@ type ProjectSettingsSchema = Omit<ProjectSettings, MovedProjectSettingsKey>;
|
||||
/** 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<GlobalSettings>;
|
||||
|
||||
|
||||
@@ -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<string, string>;
|
||||
@@ -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
|
||||
|
||||
@@ -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<Task | TaskDetail | null>(null);
|
||||
const boardScrollSnapshotRef = useRef<BoardScrollSnapshot | null>(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<PluginDashboardViewEntry[]>(() => {
|
||||
/*
|
||||
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")}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -82,6 +82,7 @@ interface AppModalsProps {
|
||||
setColorTheme: (theme: ColorTheme) => void;
|
||||
setDashboardFontScalePct: (scalePct: number) => void;
|
||||
setShadcnCustomColors: (colors: Record<string, string>) => 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={() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ? (
|
||||
<div className="chat-sidebar-scope-toggle chat-view-header-scope-toggle" role="tablist" data-testid="chat-sidebar-scope-toggle">
|
||||
@@ -3230,7 +3234,8 @@ export function ChatView({ projectId, addToast, experimentalFeatures, floating =
|
||||
data-testid="chat-sidebar-scope-direct"
|
||||
onClick={() => setChatScope("direct")}
|
||||
>
|
||||
{t("chat.scopeDirect", "Direct")}
|
||||
<MessageSquare size={14} aria-hidden="true" />
|
||||
<span>{t("chat.scopeDirect", "Direct")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -3240,7 +3245,8 @@ export function ChatView({ projectId, addToast, experimentalFeatures, floating =
|
||||
data-testid="chat-sidebar-scope-rooms"
|
||||
onClick={() => setChatScope("rooms")}
|
||||
>
|
||||
{t("chat.scopeRooms", "Rooms")}
|
||||
<Hash size={14} aria-hidden="true" />
|
||||
<span>{t("chat.scopeRooms", "Rooms")}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hideFullNav && (
|
||||
<div
|
||||
id="header-workflow-slot"
|
||||
className="header-workflow-slot header-workflow-slot--mobile"
|
||||
data-testid="header-workflow-slot"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Project Selector - Back button when project selected, dropdown when 2+ projects (tablet + desktop) */}
|
||||
{!isMobile && projects.length >= 1 && onViewAllProjects && (
|
||||
<StandaloneProjectSelector
|
||||
@@ -664,8 +675,8 @@ export function Header({
|
||||
<button
|
||||
className={`view-toggle-btn${view === "command-center" ? " active" : ""}`}
|
||||
onClick={() => 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"
|
||||
>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div className="mission-list">
|
||||
@@ -4568,18 +4567,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMobileTopPlanButton && (
|
||||
<div className="mission-list__top-action">
|
||||
<button
|
||||
className="btn btn-sm btn-task-create mission-list__primary-cta"
|
||||
onClick={openNewMissionInterview}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
{t("missions.planNewMission", "Plan New Mission")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mission and interview items */}
|
||||
{missionInterviewDrafts.length > 0 && (
|
||||
<div className="mission-list__drafts-group">
|
||||
@@ -4715,8 +4702,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
<div className="mission-list__footer">
|
||||
{showBottomPlanButton && (
|
||||
<div className="mission-list__footer-actions">
|
||||
<button className="mission-add-btn" onClick={openNewMissionInterview}>
|
||||
<Sparkles size={16} />
|
||||
<button className="btn btn-sm btn-primary mission-list__primary-cta" onClick={openNewMissionInterview}>
|
||||
<Sparkles size={14} />
|
||||
{t("missions.planNewMission", "Plan New Mission")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = (
|
||||
<ViewHeader
|
||||
icon={LayoutGrid}
|
||||
title={t("dashboard.title", "Dashboard")}
|
||||
title={t("dashboard.title", "Project Dashboard")}
|
||||
actions={(
|
||||
<button
|
||||
className="btn btn-primary btn-sm project-overview__add-btn"
|
||||
|
||||
@@ -280,7 +280,6 @@ const KNOWN_EXPERIMENTAL_FEATURES: Record<string, string> = {
|
||||
/* 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<string, string> = {
|
||||
};
|
||||
|
||||
/*
|
||||
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<string>(["leftSidebarNav", "rightDock"]);
|
||||
const HIDDEN_EXPERIMENTAL_FEATURE_KEYS = new Set<string>([
|
||||
"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<string>(["leftSidebarNav"]);
|
||||
|
||||
const EXPERIMENTAL_FEATURE_LEGACY_ALIASES: Record<string, string> = {
|
||||
devServer: "devServerView",
|
||||
@@ -369,6 +385,8 @@ interface SettingsModalProps {
|
||||
onDashboardFontScaleChange?: (scalePct: number) => void;
|
||||
/** Called when shadcn-custom color overrides change */
|
||||
onShadcnCustomColorsChange?: (colors: Record<string, string>) => 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<number | undefined>(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":
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -2764,23 +2764,19 @@ export function TaskDetailContent({
|
||||
</span>
|
||||
</div>
|
||||
<div className="modal-header-actions">
|
||||
{/*
|
||||
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 && (
|
||||
<button
|
||||
type="button"
|
||||
className="task-detail-header-back-btn"
|
||||
onClick={onBackToBoard}
|
||||
className="modal-edit-btn"
|
||||
onClick={enterEditMode}
|
||||
title={t("taskDetail.header.editTask", "Edit task")}
|
||||
aria-label={t("taskDetail.header.editTask", "Edit task")}
|
||||
>
|
||||
<ArrowLeft size={14} aria-hidden="true" />
|
||||
<span>{t("app.taskDetail.backToBoard", "Back to board")}</span>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
)}
|
||||
{/*
|
||||
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 && (
|
||||
<button
|
||||
@@ -2794,14 +2790,18 @@ export function TaskDetailContent({
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
{!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 && (
|
||||
<button
|
||||
className="modal-edit-btn"
|
||||
onClick={enterEditMode}
|
||||
title={t("taskDetail.header.editTask", "Edit task")}
|
||||
aria-label={t("taskDetail.header.editTask", "Edit task")}
|
||||
type="button"
|
||||
className="task-detail-header-back-btn"
|
||||
onClick={onBackToBoard}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
<ArrowLeft size={14} aria-hidden="true" />
|
||||
<span>{t("app.taskDetail.backToBoard", "Back to board")}</span>
|
||||
</button>
|
||||
)}
|
||||
{embedded && onRequestClose && !onBackToBoard && (
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -95,6 +95,22 @@ export function TodoView({
|
||||
() => sortItems(items.filter((item) => item.listId === selectedListId)),
|
||||
[items, selectedListId],
|
||||
);
|
||||
const listItemStats = useMemo(() => {
|
||||
const stats = new Map<string, { total: number; completed: number }>();
|
||||
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 (
|
||||
<div
|
||||
@@ -473,6 +490,9 @@ export function TodoView({
|
||||
data-testid={`todo-list-${list.id}`}
|
||||
>
|
||||
<span className="todo-list-item-name">{list.title}</span>
|
||||
<span className="todo-list-item-count">
|
||||
{stats.completed}/{stats.total}
|
||||
</span>
|
||||
</button>
|
||||
<div className="todo-list-item-actions">
|
||||
<button
|
||||
@@ -535,7 +555,17 @@ export function TodoView({
|
||||
>
|
||||
<ChevronLeft />
|
||||
</button>
|
||||
<h3>{selectedList.title}</h3>
|
||||
<div className="todo-items-heading">
|
||||
<h3>{selectedList.title}</h3>
|
||||
{selectedListStats && (
|
||||
<span className="todo-items-progress">
|
||||
{t("todo.completedCount", "{{completed}}/{{total}} complete", {
|
||||
completed: selectedListStats.completed,
|
||||
total: selectedListStats.total,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="todo-add-item-row">
|
||||
@@ -561,6 +591,7 @@ export function TodoView({
|
||||
void handleAddItem();
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("actions.add", "Add")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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") => (
|
||||
<span className={`workflow-switcher-counts workflow-switcher-counts--${variant}`} aria-hidden="true">
|
||||
{workflowCounts.merging > 0 ? (
|
||||
<span
|
||||
className="workflow-switcher-merging-indicator"
|
||||
title={t("workflowSwitcher.mergingTitle", "{{count}} merging", { count: workflowCounts.merging })}
|
||||
/>
|
||||
) : null}
|
||||
<span className="workflow-switcher-count workflow-switcher-count--todo" title={`${todoLabel}: ${workflowCounts.todo}`}>{workflowCounts.todo}</span>
|
||||
<span className="workflow-switcher-count-separator">·</span>
|
||||
<span className="workflow-switcher-count workflow-switcher-count--in-progress" title={`${inProgressLabel}: ${workflowCounts.inProgress}`}>{workflowCounts.inProgress}</span>
|
||||
@@ -280,13 +287,14 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, l
|
||||
|
||||
const renderAccessibleCounts = (workflowCounts: WorkflowStatusCounts) => (
|
||||
<span className="visually-hidden">
|
||||
{t("workflowSwitcher.countsAria", "{{todoLabel}}: {{todo}}, {{inProgressLabel}}: {{inProgress}}, {{doneLabel}}: {{done}}", {
|
||||
{t("workflowSwitcher.countsAria", "{{todoLabel}}: {{todo}}, {{inProgressLabel}}: {{inProgress}}, {{doneLabel}}: {{done}}{{mergingSuffix}}", {
|
||||
todoLabel,
|
||||
todo: workflowCounts.todo,
|
||||
inProgressLabel,
|
||||
inProgress: workflowCounts.inProgress,
|
||||
doneLabel,
|
||||
done: workflowCounts.done,
|
||||
mergingSuffix: workflowCounts.merging > 0 ? `, ${mergingLabel}: ${workflowCounts.merging}` : "",
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -2284,6 +2284,26 @@ describe("App view switching", () => {
|
||||
localStorage.removeItem("kb-dashboard-view-mode");
|
||||
});
|
||||
|
||||
it("shows Roadmaps under Missions when the roadmap experiment is enabled but the plugin API returns no views", async () => {
|
||||
/*
|
||||
FNXC:RoadmapsNavigation 2026-06-22-18:00:
|
||||
Regression guard for the roadmap experiment: enabling `experimentalFeatures.roadmap` must expose the bundled Roadmaps sidebar destination even when /plugins/dashboard-views returns an empty list.
|
||||
*/
|
||||
mockUseViewportMode.mockReturnValue("desktop");
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
experimentalFeatures: { ...defaultSettings.experimentalFeatures, roadmap: true },
|
||||
});
|
||||
(fetchPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
|
||||
|
||||
render(<App />);
|
||||
|
||||
const roadmaps = await screen.findByTestId("sidebar-nav-plugin-fusion-plugin-roadmap-roadmaps");
|
||||
const missions = screen.getByTestId("sidebar-nav-missions");
|
||||
const navItems = Array.from(screen.getByRole("navigation", { name: "Primary navigation" }).querySelectorAll(".left-sidebar-nav__item"));
|
||||
expect(navItems.indexOf(roadmaps)).toBe(navItems.indexOf(missions) + 1);
|
||||
});
|
||||
|
||||
it("restores board and plugin routes when persisted taskView changes across remounts", async () => {
|
||||
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||
|
||||
|
||||
@@ -3232,10 +3232,17 @@ describe("ChatView CSS — active state edge highlights", () => {
|
||||
const headerActiveScopeRule = findRule(".chat-view-header-scope-toggle .chat-sidebar-scope-btn--active");
|
||||
|
||||
expect(headerScopeRule).toContain("border: 1px solid var(--border)");
|
||||
expect(headerScopeRule).toContain("height: var(--view-header-content-row, 28px)");
|
||||
expect(headerScopeButtonRule).toContain("border: 1px solid transparent");
|
||||
expect(headerScopeButtonRule).toContain("height: 100%");
|
||||
expect(headerActiveScopeRule).toContain("border-color: var(--todo)");
|
||||
});
|
||||
|
||||
it("collapses header Direct/Rooms labels to icons at very narrow widths", async () => {
|
||||
expect(css).toMatch(/@media\s*\(max-width:\s*460px\)[\s\S]*?\.chat-view-header-scope-toggle\s*\{[^}]*width:\s*72px/);
|
||||
expect(css).toMatch(/@media\s*\(max-width:\s*460px\)[\s\S]*?\.chat-view-header-scope-toggle \.chat-sidebar-scope-btn span\s*\{[^}]*clip:\s*rect\(0 0 0 0\)/);
|
||||
});
|
||||
|
||||
it("keeps active chat-row background without the removed left edge or offset", async () => {
|
||||
const activeSessionRule = findRule(".chat-session-item--active");
|
||||
|
||||
@@ -4096,6 +4103,16 @@ describe("Chat pop-out header actions", () => {
|
||||
expect(css).toMatch(/\.chat-view--narrow \.chat-view__body\s*\{[^}]*flex-direction:\s*column;/);
|
||||
expect(css).toMatch(/\.chat-view--narrow \.chat-sidebar\s*\{[^}]*min-width:\s*100%;[^}]*border-right:\s*none;/);
|
||||
expect(css).toMatch(/\.chat-view--narrow \.chat-sidebar:not\(\.chat-sidebar--hidden\) \+ \.chat-thread\s*\{[^}]*display:\s*none;/);
|
||||
expect(css).toMatch(/\.chat-view--narrow \[data-testid="chat-modal-maximize"\]\s*\{[^}]*display:\s*none;/);
|
||||
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-view \[data-testid="chat-modal-maximize"\]\s*\{[^}]*display:\s*none;/);
|
||||
});
|
||||
|
||||
it("collapses Direct/Rooms labels from ChatView container width so the header title remains visible", async () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
expect(css).toMatch(/\.chat-view\s*\{[^}]*container:\s*chat-view \/ inline-size;/);
|
||||
expect(css).toMatch(/@container\s+chat-view\s+\(max-width:\s*560px\)[\s\S]*?\.chat-view-header-scope-toggle\s*\{[^}]*width:\s*72px;/);
|
||||
expect(css).toMatch(/@container\s+chat-view\s+\(max-width:\s*560px\)[\s\S]*?\.chat-view-header-scope-toggle \.chat-sidebar-scope-btn span\s*\{[^}]*clip-path:\s*inset\(50%\);/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -24,10 +24,16 @@ function extractRuleBlock(source: string, selector: string): string {
|
||||
}
|
||||
|
||||
describe("Header CSS", () => {
|
||||
it("keeps the dashboard top header divider visible by default", () => {
|
||||
it("keeps the dashboard top shell header seamless by default", () => {
|
||||
const block = extractRuleBlock(css, ".header");
|
||||
|
||||
expect(block).toContain("background: var(--surface);");
|
||||
expect(block).toContain("border-bottom: 1px solid var(--border);");
|
||||
expect(block).toContain("border-bottom: none;");
|
||||
});
|
||||
|
||||
it("compacts the workflow portal in the mobile top header", () => {
|
||||
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.header-workflow-slot\s*\{[^}]*max-width:\s*min\(42vw,\s*calc\(var\(--space-2xl\) \* 4\)\);/);
|
||||
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.header-workflow-slot \.workflow-switcher-label\s*\{[^}]*display:\s*none;/);
|
||||
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.header-workflow-slot \.workflow-switcher-counts\s*\{[^}]*display:\s*none;/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,11 +167,14 @@ describe("Header", () => {
|
||||
expect(screen.queryByTitle("List view")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render the workflow portal slot on mobile sidebar nav", () => {
|
||||
renderHeader({ onChangeView: noop, leftSidebarNavActive: true }, "mobile");
|
||||
expect(screen.queryByTestId("header-workflow-slot")).toBeNull();
|
||||
expect(screen.queryByTitle("Board view")).not.toBeNull();
|
||||
expect(screen.queryByTitle("List view")).not.toBeNull();
|
||||
it("renders the workflow portal slot in the mobile top header when mobile nav owns view switching", () => {
|
||||
renderHeader({ onChangeView: noop, leftSidebarNavActive: true, mobileNavEnabled: true }, "mobile");
|
||||
const workflowSlot = screen.getByTestId("header-workflow-slot");
|
||||
expect(workflowSlot).toBeInTheDocument();
|
||||
expect(workflowSlot).toHaveClass("header-workflow-slot--mobile");
|
||||
expect(workflowSlot.closest(".header-left")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mobile-view-toggle-board")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mobile-view-toggle-list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows board view as active by default", () => {
|
||||
@@ -1134,11 +1137,11 @@ describe("Header", () => {
|
||||
});
|
||||
|
||||
it("can open mobile search when mobileNavEnabled is true", () => {
|
||||
renderHeader({ view: "board", searchQuery: "", onSearchChange: vi.fn(), onChangeView: noop }, "mobile");
|
||||
renderHeader({ view: "board", searchQuery: "", onSearchChange: vi.fn(), onChangeView: noop, mobileNavEnabled: true }, "mobile");
|
||||
// Should show the trigger button
|
||||
const mobileSearchTrigger = screen.getByTestId("mobile-header-search-btn");
|
||||
expect(mobileSearchTrigger).toBeDefined();
|
||||
expect(screen.queryByTestId("header-workflow-slot")).toBeNull();
|
||||
expect(screen.getByTestId("header-workflow-slot")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
|
||||
// Expanded search should not be visible initially, then opens from the unchanged mobile trigger.
|
||||
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
|
||||
|
||||
@@ -384,6 +384,29 @@ describe("LeftSidebarNav", () => {
|
||||
expect(screen.queryByRole("button", { name: /view$/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("pins the Roadmaps plugin destination directly under Missions when registered", () => {
|
||||
const roadmapView: PluginDashboardViewEntry = {
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
view: {
|
||||
viewId: "roadmaps",
|
||||
label: "Roadmaps",
|
||||
componentPath: "./RoadmapsView",
|
||||
placement: "primary",
|
||||
order: 99,
|
||||
},
|
||||
};
|
||||
renderSidebar({ pluginDashboardViews: [pluginViews[0], roadmapView, pluginViews[1]] });
|
||||
|
||||
const missions = screen.getByTestId("sidebar-nav-missions");
|
||||
const roadmaps = screen.getByTestId("sidebar-nav-plugin-fusion-plugin-roadmap-roadmaps");
|
||||
const agents = screen.getByTestId("sidebar-nav-agents");
|
||||
const navItems = Array.from(screen.getByRole("navigation", { name: "Primary navigation" }).querySelectorAll(".left-sidebar-nav__item"));
|
||||
|
||||
// FNXC:Navigation 2026-06-22-18:20: Roadmaps is a planning surface, so its plugin nav entry must sit immediately below Missions instead of sorting with generic plugin views.
|
||||
expect(navItems.indexOf(roadmaps)).toBe(navItems.indexOf(missions) + 1);
|
||||
expect(navItems.indexOf(agents)).toBe(navItems.indexOf(roadmaps) + 1);
|
||||
});
|
||||
|
||||
it("renders mailbox badges without the removed stash recovery destination", () => {
|
||||
renderSidebar();
|
||||
|
||||
|
||||
@@ -90,21 +90,15 @@ describe("MissionManager mobile styles", () => {
|
||||
expect(section).toContain("display: block;");
|
||||
});
|
||||
|
||||
it("keeps the mobile top mission CTA full-width and token-driven", () => {
|
||||
it("keeps the mobile bottom mission CTA full-width and primary-styled", () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
const topActionRule = css.match(/\.mission-list__top-action\s*\{[^}]*\}/)?.[0];
|
||||
expect(topActionRule).toContain("display: flex;");
|
||||
expect(css).not.toContain(".mission-list__top-action");
|
||||
|
||||
const topCtaRule = css.match(/\.mission-list__primary-cta\s*\{[^}]*\}/)?.[0];
|
||||
expect(topCtaRule).toContain("width: 100%;");
|
||||
expect(topCtaRule).toContain("justify-content: center;");
|
||||
expect(topCtaRule).toContain("gap: var(--space-sm);");
|
||||
|
||||
const taskCreateRule = css.match(/\.btn-task-create\s*\{[^}]*\}/)?.[0];
|
||||
expect(taskCreateRule).toContain("background: var(--cta-bg);");
|
||||
expect(taskCreateRule).toContain("border-color: var(--cta-border);");
|
||||
expect(taskCreateRule).toContain("color: var(--cta-text);");
|
||||
});
|
||||
|
||||
it("hides back button on desktop and restores it on mobile", () => {
|
||||
|
||||
@@ -853,6 +853,18 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("disables concurrency inputs until their actual values load", async () => {
|
||||
mockFetchGlobalConcurrency.mockReturnValue(new Promise(() => {}));
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Scheduling/ }));
|
||||
|
||||
expect(screen.getByLabelText("Global Max Concurrent")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Max Concurrent Tasks")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Max Triage Concurrent")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables memory backend status hook only when Memory section is active", async () => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
@@ -1050,6 +1062,16 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getByRole("option", { name: "Require changelog update (existing changelog)" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports Quick Chat launcher changes immediately before save", async () => {
|
||||
const onQuickChatButtonModeChange = vi.fn();
|
||||
renderModal({ initialSection: "general", onQuickChatButtonModeChange });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await userEvent.selectOptions(screen.getByLabelText("Quick Chat launcher"), "footer");
|
||||
|
||||
expect(onQuickChatButtonModeChange).toHaveBeenCalledWith("footer");
|
||||
});
|
||||
|
||||
it.each<PersistSettingInput>([
|
||||
{
|
||||
section: "Project General",
|
||||
@@ -2988,6 +3010,7 @@ describe("SettingsModal", () => {
|
||||
|
||||
const input = screen.getByLabelText("Max Concurrent Tasks") as HTMLInputElement;
|
||||
expect(input).toBeDefined();
|
||||
await waitFor(() => expect(input).not.toBeDisabled());
|
||||
|
||||
// Clear the input - the input should be empty, not show "0"
|
||||
await userEvent.clear(input);
|
||||
@@ -3003,6 +3026,7 @@ describe("SettingsModal", () => {
|
||||
|
||||
const input = screen.getByLabelText("Global Max Concurrent") as HTMLInputElement;
|
||||
expect(input).toBeDefined();
|
||||
await waitFor(() => expect(input).not.toBeDisabled());
|
||||
|
||||
// Clear the input - the input should be empty, not show "0"
|
||||
await userEvent.clear(input);
|
||||
@@ -3833,6 +3857,8 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getByLabelText(featureLabel)).toBeInTheDocument();
|
||||
}
|
||||
|
||||
expect(screen.queryByLabelText("Right Dock Panel")).not.toBeInTheDocument();
|
||||
|
||||
// Dev Server has a single canonical toggle (no legacy duplicate).
|
||||
expect(screen.getAllByLabelText("Dev Server")).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -65,6 +65,24 @@ function createDeferred<T>() {
|
||||
}
|
||||
|
||||
describe("TaskDetailModal summarize title action", () => {
|
||||
it("orders board detail header actions as edit, expand, then Back to board", () => {
|
||||
const onBackToBoard = vi.fn();
|
||||
const onPopOut = vi.fn();
|
||||
renderSummarizeTitleModal(
|
||||
{ column: "todo" as any },
|
||||
{ embedded: true, onBackToBoard, onPopOut },
|
||||
);
|
||||
|
||||
const actions = document.querySelector(".modal-header-actions");
|
||||
expect(actions).not.toBeNull();
|
||||
const editButton = screen.getByRole("button", { name: "Edit task" });
|
||||
const popOutButton = screen.getByTestId("task-detail-pop-out");
|
||||
const backButton = screen.getByRole("button", { name: /back to board/i });
|
||||
|
||||
// FNXC:TaskDetail 2026-06-22-18:32: Board task-detail action order is edit, expand/pop-out, then Back to board pinned far right.
|
||||
expect(Array.from(actions!.children)).toEqual([editButton, popOutButton, backButton]);
|
||||
});
|
||||
|
||||
it("renders when the task is editable and has a description", () => {
|
||||
renderSummarizeTitleModal({ column: "todo" as any });
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("ThemeDropdown", () => {
|
||||
|
||||
const trigger = screen.getByRole("button", { name: /ocean/i });
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(within(trigger).getByText("Ocean")).toBeDefined();
|
||||
expect(within(trigger).getByText("Ocean (Default)")).toBeDefined();
|
||||
expect(trigger.querySelector(".theme-swatch-ocean")).toBeTruthy();
|
||||
|
||||
fireEvent.click(trigger);
|
||||
@@ -33,16 +33,16 @@ describe("ThemeDropdown", () => {
|
||||
const onColorThemeChange = vi.fn();
|
||||
render(<ThemeDropdown colorTheme="default" onColorThemeChange={onColorThemeChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /default/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /fusion legacy/i }));
|
||||
fireEvent.click(screen.getAllByRole("option").find((element) => element.textContent?.trim() === "Forest")!);
|
||||
expect(onColorThemeChange).toHaveBeenCalledWith("forest");
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /default/i }));
|
||||
fireEvent.keyDown(screen.getByRole("option", { name: /default/i }), { key: "Escape" });
|
||||
fireEvent.click(screen.getByRole("button", { name: /fusion legacy/i }));
|
||||
fireEvent.keyDown(screen.getByRole("option", { name: /fusion legacy/i }), { key: "Escape" });
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /default/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /fusion legacy/i }));
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
});
|
||||
@@ -51,9 +51,9 @@ describe("ThemeDropdown", () => {
|
||||
const onColorThemeChange = vi.fn();
|
||||
render(<ThemeDropdown colorTheme="default" onColorThemeChange={onColorThemeChange} />);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: /default/i });
|
||||
const trigger = screen.getByRole("button", { name: /fusion legacy/i });
|
||||
fireEvent.keyDown(trigger, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(screen.getByRole("option", { name: /default/i }), { key: "ArrowDown" });
|
||||
fireEvent.keyDown(screen.getByRole("option", { name: /fusion legacy/i }), { key: "ArrowDown" });
|
||||
fireEvent.keyDown(screen.getByRole("option", { name: /ocean/i }), { key: "Enter" });
|
||||
|
||||
expect(onColorThemeChange).toHaveBeenCalledWith("ocean");
|
||||
@@ -107,7 +107,7 @@ describe("ThemeDropdown", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: /default/i });
|
||||
const trigger = screen.getByRole("button", { name: /fusion legacy/i });
|
||||
const root = trigger.closest(".theme-dropdown");
|
||||
expect(root).toBeTruthy();
|
||||
expect(root?.classList.contains("open")).toBe(false);
|
||||
|
||||
@@ -114,7 +114,7 @@ describe("ThemeSelector", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const oceanBtn = screen.getByLabelText("Ocean theme");
|
||||
const oceanBtn = screen.getByLabelText("Ocean (Default) theme");
|
||||
expect(oceanBtn.className).toContain("active");
|
||||
expect(oceanBtn.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
@@ -487,7 +487,7 @@ describe("ThemeSelector", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Current theme/)).toBeDefined();
|
||||
expect(screen.getByText(/Dark \/ Ocean/)).toBeDefined();
|
||||
expect(screen.getByText(/Dark \/ Ocean \(Default\)/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays system theme in preview when system mode", () => {
|
||||
@@ -598,7 +598,7 @@ describe("ThemeSelector", () => {
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Reset to default theme"));
|
||||
expect(onThemeModeChange).toHaveBeenCalledWith("dark");
|
||||
expect(onColorThemeChange).toHaveBeenCalledWith("default");
|
||||
expect(onColorThemeChange).toHaveBeenCalledWith("ocean");
|
||||
});
|
||||
|
||||
it("shows the shadcn custom picker only for shadcn-custom", () => {
|
||||
|
||||
@@ -68,7 +68,7 @@ describe("WorkflowSwitcher", () => {
|
||||
workflows={workflows}
|
||||
value="coding"
|
||||
onChange={vi.fn()}
|
||||
counts={countMap([["coding", { todo: 3, inProgress: 1, done: 5 }]])}
|
||||
counts={countMap([["coding", { todo: 3, inProgress: 1, done: 5, merging: 0 }]])}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -349,7 +349,7 @@ describe("WorkflowSwitcher", () => {
|
||||
workflows={workflows}
|
||||
value="coding"
|
||||
onChange={vi.fn()}
|
||||
counts={countMap([["coding", { todo: 3, inProgress: 1, done: 5 }]])}
|
||||
counts={countMap([["coding", { todo: 3, inProgress: 1, done: 5, merging: 0 }]])}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -373,6 +373,29 @@ describe("WorkflowSwitcher", () => {
|
||||
expect(within(designOption).getByText("0", { selector: ".workflow-switcher-count--done" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a merging indicator only for workflows with merging tasks", () => {
|
||||
render(
|
||||
<WorkflowSwitcher
|
||||
workflows={workflows}
|
||||
value="coding"
|
||||
onChange={vi.fn()}
|
||||
counts={countMap([
|
||||
["coding", { todo: 3, inProgress: 1, done: 5, merging: 1 }],
|
||||
["design", { todo: 0, inProgress: 2, done: 0, merging: 0 }],
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId("workflow-switcher");
|
||||
expect(trigger.querySelector(".workflow-switcher-merging-indicator")).toBeNull();
|
||||
|
||||
fireEvent.click(trigger);
|
||||
|
||||
expect(trigger.querySelector(".workflow-switcher-merging-indicator")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workflow-switcher-option-coding").querySelector(".workflow-switcher-merging-indicator")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workflow-switcher-option-design").querySelector(".workflow-switcher-merging-indicator")).toBeNull();
|
||||
});
|
||||
|
||||
it("colors status counts with board column color tokens", () => {
|
||||
const css = loadAllAppCssBaseOnly();
|
||||
const badgeRules = [
|
||||
@@ -388,4 +411,15 @@ describe("WorkflowSwitcher", () => {
|
||||
expect(rule).not.toMatch(/#[0-9a-fA-F]{3,8}|rgba?\(/);
|
||||
}
|
||||
});
|
||||
|
||||
it("styles the merging indicator with a flashing animation and reduced-motion fallback", () => {
|
||||
const css = loadAllAppCssBaseOnly();
|
||||
const switcherCss = readFileSync("app/components/WorkflowSwitcher.css", "utf8");
|
||||
const indicatorRule = cssRuleFor(css, ".workflow-switcher-merging-indicator");
|
||||
|
||||
expect(indicatorRule).toContain("background: var(--color-warning);");
|
||||
expect(indicatorRule).toContain("animation: workflow-switcher-merging-pulse");
|
||||
expect(switcherCss).toContain("@keyframes workflow-switcher-merging-pulse");
|
||||
expect(switcherCss).toMatch(/@media\s*\(prefers-reduced-motion:\s*reduce\)[\s\S]*?\.workflow-switcher-merging-indicator\s*\{[^}]*animation:\s*none;/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -469,6 +469,6 @@ describe("SettingsModal mobile adaptations", () => {
|
||||
|
||||
expectBaseRule(css, ".settings-section-heading", "padding: var(--space-lg) 0 var(--space-md);");
|
||||
expectBaseRule(css, ".settings-section-heading", "margin: 0;");
|
||||
expectBaseRule(css, ".settings-section-heading", "border-bottom: 1px solid var(--border);");
|
||||
expect(css).not.toMatch(/\.settings-section-heading\s*\{[^}]*border-bottom:\s*1px solid var\(--border\);/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,13 @@ function task(id: string, column: string): Task {
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function taskWithStatus(id: string, column: string, status: string): Task {
|
||||
return {
|
||||
...task(id, column),
|
||||
status,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function builtinWorkflowColumns(id: string): BoardWorkflowColumn[] {
|
||||
const workflow = getBuiltinWorkflow(id);
|
||||
if (!workflow) throw new Error(`Missing built-in workflow fixture: ${id}`);
|
||||
@@ -106,9 +113,9 @@ describe("computeWorkflowStatusCounts", () => {
|
||||
it("initializes every workflow with zero counts for empty and duplicate/populated states", () => {
|
||||
const counts = computeWorkflowStatusCounts([], boardWorkflows);
|
||||
|
||||
expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 0 });
|
||||
expect(counts.get("design")).toEqual({ todo: 0, inProgress: 0, done: 0 });
|
||||
expect(counts.get("empty")).toEqual({ todo: 0, inProgress: 0, done: 0 });
|
||||
expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 0, merging: 0 });
|
||||
expect(counts.get("design")).toEqual({ todo: 0, inProgress: 0, done: 0, merging: 0 });
|
||||
expect(counts.get("empty")).toEqual({ todo: 0, inProgress: 0, done: 0, merging: 0 });
|
||||
});
|
||||
|
||||
it("classifies todo, in-progress, and done buckets from workflow column flags", () => {
|
||||
@@ -123,7 +130,7 @@ describe("computeWorkflowStatusCounts", () => {
|
||||
boardWorkflows
|
||||
);
|
||||
|
||||
expect(counts.get("default")).toEqual({ todo: 2, inProgress: 2, done: 1 });
|
||||
expect(counts.get("default")).toEqual({ todo: 2, inProgress: 2, done: 1, merging: 0 });
|
||||
});
|
||||
|
||||
it("keeps flag-based classification authoritative over canonical lifecycle ids", () => {
|
||||
@@ -156,6 +163,7 @@ describe("computeWorkflowStatusCounts", () => {
|
||||
todo: 0,
|
||||
inProgress: 1,
|
||||
done: 1,
|
||||
merging: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,7 +173,7 @@ describe("computeWorkflowStatusCounts", () => {
|
||||
boardWorkflows
|
||||
);
|
||||
|
||||
expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 1 });
|
||||
expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 1, merging: 0 });
|
||||
});
|
||||
|
||||
it("counts tasks independently for their assigned workflow", () => {
|
||||
@@ -185,8 +193,28 @@ describe("computeWorkflowStatusCounts", () => {
|
||||
}
|
||||
);
|
||||
|
||||
expect(counts.get("design")).toEqual({ todo: 1, inProgress: 1, done: 1 });
|
||||
expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 0 });
|
||||
expect(counts.get("design")).toEqual({ todo: 1, inProgress: 1, done: 1, merging: 0 });
|
||||
expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 0, merging: 0 });
|
||||
});
|
||||
|
||||
it("tracks actively merging tasks per workflow separately from bucket counts", () => {
|
||||
const counts = computeWorkflowStatusCounts(
|
||||
[
|
||||
taskWithStatus("FN-default-merging", "review", "merging"),
|
||||
taskWithStatus("FN-design-merging-fix", "design-active", "merging-fix"),
|
||||
taskWithStatus("FN-design-normal", "design-active", "executing"),
|
||||
],
|
||||
{
|
||||
...boardWorkflows,
|
||||
taskWorkflowIds: {
|
||||
"FN-design-merging-fix": "design",
|
||||
"FN-design-normal": "design",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(counts.get("default")).toEqual({ todo: 0, inProgress: 1, done: 0, merging: 1 });
|
||||
expect(counts.get("design")).toEqual({ todo: 0, inProgress: 2, done: 0, merging: 1 });
|
||||
});
|
||||
|
||||
it("excludes archived-column tasks and ignores unknown workflows or columns", () => {
|
||||
@@ -204,7 +232,7 @@ describe("computeWorkflowStatusCounts", () => {
|
||||
}
|
||||
);
|
||||
|
||||
expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 0 });
|
||||
expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 0, merging: 0 });
|
||||
});
|
||||
|
||||
it("uses real quick-fix empty-trait columns to count the reported two done and zero in-progress state", () => {
|
||||
@@ -222,6 +250,7 @@ describe("computeWorkflowStatusCounts", () => {
|
||||
todo: 0,
|
||||
inProgress: 0,
|
||||
done: 2,
|
||||
merging: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -252,6 +281,7 @@ describe("computeWorkflowStatusCounts", () => {
|
||||
todo: 3,
|
||||
inProgress: 1,
|
||||
done: 1,
|
||||
merging: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -262,7 +292,7 @@ describe("computeWorkflowStatusCounts", () => {
|
||||
|
||||
expect(
|
||||
computeWorkflowStatusCounts([], payload).get("builtin:quick-fix")
|
||||
).toEqual({ todo: 0, inProgress: 0, done: 0 });
|
||||
).toEqual({ todo: 0, inProgress: 0, done: 0, merging: 0 });
|
||||
|
||||
const counts = computeWorkflowStatusCounts(
|
||||
[
|
||||
@@ -279,6 +309,7 @@ describe("computeWorkflowStatusCounts", () => {
|
||||
todo: 2,
|
||||
inProgress: 1,
|
||||
done: 2,
|
||||
merging: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -300,6 +331,7 @@ describe("computeWorkflowStatusCounts", () => {
|
||||
todo: 1,
|
||||
inProgress: 1,
|
||||
done: 1,
|
||||
merging: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,10 +14,10 @@ FN-6690 fix: Command Center CSS was authored against a numeric token scale (--sp
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
inline-size: 100%;
|
||||
padding: var(--space-lg);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -34,8 +34,37 @@ The Command Center must remain scrollable on mobile inside the overflow-hidden .
|
||||
|
||||
/*
|
||||
FNXC:ViewHeader 2026-06-23-03:45:
|
||||
cc-title is the original model for the shared ViewHeader; its visible text metrics are kept in lockstep with the canonical ViewHeader title (1.125rem / 600 / var(--text)) and a --todo-colored leading icon so the Command Center heading reads identically to every other view. The Command Center shell keeps its own pinned-header + tablist-divider + scrollable-tabpanel layout (the .command-center shell supplies the --space-lg padding and the .cc-tablist below owns the single divider), so the container does not adopt ViewHeader's edge-to-edge surface/border-bottom chrome — doing so would double the divider against the tablist.
|
||||
cc-title is the original model for the shared ViewHeader; its visible text metrics are kept in lockstep with the canonical ViewHeader title (1.125rem / 600 / var(--text)) and a --todo-colored leading icon so the Dashboard heading reads identically to every other view.
|
||||
|
||||
FNXC:CommandCenterStyling 2026-06-22-20:05:
|
||||
Command Center is user-facing Dashboard now, and its header must match Missions/Planning style: edge-to-edge surface background with no divider after the header. The tabs keep their own local selection affordance, while the body owns the old page padding.
|
||||
|
||||
FNXC:DashboardHeader 2026-06-22-18:00:
|
||||
Dashboard follows the global header rule: surface background, canonical height/padding, and no bottom border line.
|
||||
*/
|
||||
.cc-header {
|
||||
box-sizing: border-box;
|
||||
min-block-size: var(--view-header-min-height);
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
@media (min-width: 769px) and (min-height: 481px) {
|
||||
.cc-header {
|
||||
height: var(--view-header-min-height);
|
||||
}
|
||||
|
||||
.cc-header > .cc-date-range {
|
||||
max-height: var(--view-header-content-row);
|
||||
}
|
||||
|
||||
.cc-header > .cc-date-range .cc-date-range-trigger {
|
||||
min-height: 0;
|
||||
max-height: var(--view-header-content-row);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -57,6 +86,7 @@ cc-title is the original model for the shared ViewHeader; its visible text metri
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-md) var(--space-xl) 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
@@ -89,6 +119,7 @@ cc-title is the original model for the shared ViewHeader; its visible text metri
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
outline: none;
|
||||
padding: var(--space-lg) var(--space-xl) var(--space-xl);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
|
||||
@@ -296,7 +296,7 @@ function OverviewTab({
|
||||
{controlsSection}
|
||||
<div className="cc-loading" data-testid="command-center-overview-loading">
|
||||
<div className="cc-chart-skeleton" />
|
||||
<p><LoadingSpinner label={t("commandCenter.loading", "Loading command center...")} /></p>
|
||||
<p><LoadingSpinner label={t("commandCenter.loading", "Loading dashboard...")} /></p>
|
||||
</div>
|
||||
{throughputSection}
|
||||
</div>
|
||||
@@ -322,7 +322,7 @@ function OverviewTab({
|
||||
{controlsSection}
|
||||
<div className="cc-empty" data-testid="command-center-empty">
|
||||
<Gauge size={28} />
|
||||
<p>{t("commandCenter.empty", "No usage data yet. Run some agents to populate the Command Center.")}</p>
|
||||
<p>{t("commandCenter.empty", "No usage data yet. Run some agents to populate the Dashboard.")}</p>
|
||||
</div>
|
||||
{throughputSection}
|
||||
</div>
|
||||
@@ -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). */}
|
||||
<h2 className="cc-title">
|
||||
<Gauge size={20} />
|
||||
{t("commandCenter.heading", "Command Center")}
|
||||
{t("commandCenter.heading", "Dashboard")}
|
||||
</h2>
|
||||
<DateRangePicker value={range} onChange={setRange} />
|
||||
</header>
|
||||
@@ -580,7 +580,7 @@ export function CommandCenter({
|
||||
<div
|
||||
className="cc-tablist"
|
||||
role="tablist"
|
||||
aria-label={t("commandCenter.tablistLabel", "Command Center sections")}
|
||||
aria-label={t("commandCenter.tablistLabel", "Dashboard sections")}
|
||||
>
|
||||
{subViews.map((sub, index) => {
|
||||
const selected = sub.id === activeTab;
|
||||
|
||||
@@ -11,14 +11,16 @@ export interface ExperimentalSectionProps extends SectionBaseProps {
|
||||
getCanonicalKey: (key: string) => string;
|
||||
/** Whether a feature is enabled, honoring legacy aliases. */
|
||||
isFeatureEnabled: (features: Record<string, boolean>, key: string) => boolean;
|
||||
/** Feature keys that are supported internally but should not render as user toggles. */
|
||||
hiddenFeatureKeys?: ReadonlySet<string>;
|
||||
}
|
||||
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}
|
||||
|
||||
@@ -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<WorkflowDefinition[]>([]);
|
||||
useEffect(() => {
|
||||
@@ -109,6 +110,7 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast
|
||||
<label htmlFor="quickChatButtonMode">{t("settings.general.quickChatLauncher", "Quick Chat launcher")}</label>
|
||||
<select id="quickChatButtonMode" className="select" value={form.quickChatButtonMode ?? (form.showQuickChatFAB ? "floating" : "off")} onChange={(e) => setForm((f) => {
|
||||
const mode = e.target.value as "floating" | "footer" | "off";
|
||||
onQuickChatButtonModeChange?.(mode);
|
||||
return { ...f, quickChatButtonMode: mode, showQuickChatFAB: mode === "floating" };
|
||||
})}>
|
||||
<option value="floating">{t("settings.general.quickChatLauncherFloating", "Floating button")}</option>
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface SchedulingSectionProps {
|
||||
form: SettingsFormState;
|
||||
setForm: SetSettingsForm;
|
||||
globalMaxConcurrent: number | undefined;
|
||||
concurrencyLoading?: boolean;
|
||||
onGlobalMaxConcurrentChange: (value: number | undefined) => void;
|
||||
onOverlapIgnorePathChange: (index: number, value: string) => void;
|
||||
onOpenOverlapPathPicker: (index: number) => void;
|
||||
@@ -16,14 +17,18 @@ export interface SchedulingSectionProps {
|
||||
onAddOverlapIgnorePath: () => void;
|
||||
onOpenWorkflowSettings?: () => void;
|
||||
}
|
||||
export function SchedulingSection({ scopeBanner, form, setForm, globalMaxConcurrent, onGlobalMaxConcurrentChange, onOverlapIgnorePathChange, onOpenOverlapPathPicker, onRemoveOverlapIgnorePath, onAddOverlapIgnorePath, onOpenWorkflowSettings, }: SchedulingSectionProps) {
|
||||
export function SchedulingSection({ scopeBanner, form, setForm, globalMaxConcurrent, concurrencyLoading = false, onGlobalMaxConcurrentChange, onOverlapIgnorePathChange, onOpenOverlapPathPicker, onRemoveOverlapIgnorePath, onAddOverlapIgnorePath, onOpenWorkflowSettings, }: SchedulingSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.scheduling.scheduling", "Scheduling")}</h4>
|
||||
{/*
|
||||
FNXC:SettingsConcurrency 2026-06-22-20:18:
|
||||
Concurrency inputs represent live project/global limits. Keep them disabled while their actual values are still loading so users cannot edit a blank fallback and accidentally overwrite the resolved limits.
|
||||
*/}
|
||||
<div className="form-group">
|
||||
<label htmlFor="globalMaxConcurrent">{t("settings.scheduling.globalMaxConcurrent", "Global Max Concurrent")}</label>
|
||||
<input id="globalMaxConcurrent" type="number" min={0} max={10000} value={globalMaxConcurrent ?? ""} onChange={(e) => {
|
||||
<input id="globalMaxConcurrent" type="number" min={0} max={10000} disabled={concurrencyLoading} value={globalMaxConcurrent ?? ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
onGlobalMaxConcurrentChange(val === "" ? undefined : Number(val));
|
||||
}}/>
|
||||
@@ -31,14 +36,14 @@ export function SchedulingSection({ scopeBanner, form, setForm, globalMaxConcurr
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="maxConcurrent">{t("settings.scheduling.maxConcurrentTasks", "Max Concurrent Tasks")}</label>
|
||||
<input id="maxConcurrent" type="number" min={1} max={10} value={form.maxConcurrent ?? ""} onChange={(e) => {
|
||||
<input id="maxConcurrent" type="number" min={1} max={10} disabled={concurrencyLoading} value={form.maxConcurrent ?? ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, maxConcurrent: val === "" ? undefined : Number(val) } as SettingsFormState));
|
||||
}}/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="maxTriageConcurrent">{t("settings.scheduling.maxTriageConcurrent", "Max Triage Concurrent")}</label>
|
||||
<input id="maxTriageConcurrent" type="number" min={1} max={10} value={form.maxTriageConcurrent ?? ""} onChange={(e) => {
|
||||
<input id="maxTriageConcurrent" type="number" min={1} max={10} disabled={concurrencyLoading} value={form.maxTriageConcurrent ?? ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, maxTriageConcurrent: val === "" ? undefined : Number(val) } as SettingsFormState));
|
||||
}}/>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface UseAppSettingsResult {
|
||||
toggleGlobalPause: () => Promise<void>;
|
||||
toggleEnginePause: () => Promise<void>;
|
||||
toggleShowQuickChatFAB: () => Promise<void>;
|
||||
setQuickChatButtonModeImmediate: (mode: QuickChatButtonMode) => void;
|
||||
toggleAutoReloadOnVersionChange: () => Promise<void>;
|
||||
/** Re-fetches settings from the backend to pick up changes made externally (e.g., by SettingsModal). */
|
||||
refresh: () => Promise<void>;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}%';
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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%';
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = `
|
||||
<main id="board">
|
||||
<section class="column" data-column="todo"><div class="column-body"></div></section>
|
||||
<section class="column" data-column="in-progress"><div class="column-body"></div></section>
|
||||
</main>
|
||||
`;
|
||||
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);
|
||||
});
|
||||
});
|
||||
54
packages/dashboard/app/utils/boardScrollSnapshot.ts
Normal file
54
packages/dashboard/app/utils/boardScrollSnapshot.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export interface BoardScrollSnapshot {
|
||||
boardLeft: number;
|
||||
boardTop: number;
|
||||
columnTops: Record<string, number>;
|
||||
}
|
||||
|
||||
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<string, number> = {};
|
||||
board.querySelectorAll<HTMLElement>(".column[data-column]").forEach((column) => {
|
||||
const columnId = column.dataset.column;
|
||||
const body = column.querySelector<HTMLElement>(".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<HTMLElement>(".column[data-column]").forEach((column) => {
|
||||
const columnId = column.dataset.column;
|
||||
const body = column.querySelector<HTMLElement>(".column-body");
|
||||
if (columnId && body && Object.prototype.hasOwnProperty.call(snapshot.columnTops, columnId)) {
|
||||
body.scrollTop = snapshot.columnTops[columnId];
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -313,6 +313,23 @@ export const mockedInstallTaskWorktreeIdentityGuard = vi.mocked(installTaskWorkt
|
||||
|
||||
export type EventListener = (...args: unknown[]) => void;
|
||||
|
||||
const withLegacyWorkflowFeatureDefaults = (settings: Record<string, unknown>) => ({
|
||||
...settings,
|
||||
experimentalFeatures: {
|
||||
workflowColumns: false,
|
||||
workflowGraphExecutor: false,
|
||||
...((settings.experimentalFeatures as Record<string, unknown> | undefined) ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
const createLegacySettingsMock = (initialSettings: Record<string, unknown>) => {
|
||||
const mock = vi.fn().mockResolvedValue(withLegacyWorkflowFeatureDefaults(initialSettings));
|
||||
const mockResolvedValue = mock.mockResolvedValue.bind(mock);
|
||||
mock.mockResolvedValue = ((settings: Record<string, unknown>) =>
|
||||
mockResolvedValue(withLegacyWorkflowFeatureDefaults(settings))) as typeof mock.mockResolvedValue;
|
||||
return mock;
|
||||
};
|
||||
|
||||
export function createMockStore() {
|
||||
const listeners = new Map<string, EventListener[]>();
|
||||
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,
|
||||
|
||||
@@ -50,10 +50,19 @@ function createMockTask(overrides: Partial<Task> = {}): Task {
|
||||
} as Task;
|
||||
}
|
||||
|
||||
const withLegacyWorkflowGraphDefault = (settings: Record<string, unknown>) => ({
|
||||
...settings,
|
||||
experimentalFeatures: {
|
||||
workflowColumns: false,
|
||||
workflowGraphExecutor: false,
|
||||
...((settings.experimentalFeatures as Record<string, unknown> | undefined) ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
function createMockStore(task: Task, settings: Record<string, unknown> = {}): 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),
|
||||
|
||||
@@ -93,8 +93,18 @@ function createMockTask(overrides: Partial<Task> = {}): Task {
|
||||
}
|
||||
|
||||
// Mock store factory
|
||||
const withLegacyWorkflowGraphDefault = (settings: Record<string, unknown>) => ({
|
||||
...settings,
|
||||
experimentalFeatures: {
|
||||
workflowColumns: false,
|
||||
workflowGraphExecutor: false,
|
||||
...((settings.experimentalFeatures as Record<string, unknown> | undefined) ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
function createMockStore(overrides: Partial<TaskStore> = {}): 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> = {}): 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<Record<string, unknown>>)(...args);
|
||||
return withLegacyWorkflowGraphDefault(settings);
|
||||
}) as typeof store.getSettings;
|
||||
return store as unknown as TaskStore;
|
||||
}
|
||||
|
||||
async function flushAsyncWork(): Promise<void> {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<div className="roadmaps-view roadmaps-view--loading">
|
||||
<div className="roadmaps-view__top-header">
|
||||
<h2 className="roadmaps-view__top-title">
|
||||
<Map size={20} />
|
||||
<span>Roadmaps</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="roadmaps-view__loading-state">Loading roadmaps...</div>
|
||||
</div>
|
||||
);
|
||||
@@ -2116,6 +2122,12 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
if (error && roadmaps.length === 0) {
|
||||
return (
|
||||
<div className="roadmaps-view roadmaps-view--error">
|
||||
<div className="roadmaps-view__top-header">
|
||||
<h2 className="roadmaps-view__top-title">
|
||||
<Map size={20} />
|
||||
<span>Roadmaps</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="roadmaps-view__error-state">
|
||||
<p>Failed to load roadmaps</p>
|
||||
<p className="roadmaps-view__error-msg">{error.message}</p>
|
||||
@@ -2126,40 +2138,51 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
|
||||
return (
|
||||
<div className="roadmaps-view">
|
||||
{/* Mobile Roadmap List (shown when mobile and no roadmap selected) */}
|
||||
{isMobile && !effectiveSelectedRoadmapId && (
|
||||
<MobileRoadmapList
|
||||
roadmaps={roadmaps}
|
||||
selectedRoadmapId={effectiveSelectedRoadmapId}
|
||||
onSelect={(id) => 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.
|
||||
*/}
|
||||
<div className="roadmaps-view__top-header">
|
||||
<h2 className="roadmaps-view__top-title">
|
||||
<Map size={20} />
|
||||
<span>Roadmaps</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="roadmaps-view__body">
|
||||
{/* Mobile Roadmap List (shown when mobile and no roadmap selected) */}
|
||||
{isMobile && !effectiveSelectedRoadmapId && (
|
||||
<MobileRoadmapList
|
||||
roadmaps={roadmaps}
|
||||
selectedRoadmapId={effectiveSelectedRoadmapId}
|
||||
onSelect={(id) => 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 && (
|
||||
<aside className="roadmaps-view__sidebar" aria-label="Roadmaps">
|
||||
<div className="roadmaps-view__sidebar-header">
|
||||
<h2 className="roadmaps-view__sidebar-title">Roadmaps</h2>
|
||||
<button
|
||||
className="roadmaps-view__add-btn"
|
||||
onClick={() => setCreateForm({ type: "roadmap", title: "", description: "" })}
|
||||
title="Create roadmap"
|
||||
aria-label="Create roadmap"
|
||||
data-testid="create-roadmap-btn"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Desktop sidebar (hidden on mobile) */}
|
||||
{!isMobile && (
|
||||
<aside className="roadmaps-view__sidebar" aria-label="Roadmaps">
|
||||
<div className="roadmaps-view__sidebar-header">
|
||||
<h2 className="roadmaps-view__sidebar-title">Roadmaps</h2>
|
||||
<button
|
||||
className="roadmaps-view__add-btn"
|
||||
onClick={() => setCreateForm({ type: "roadmap", title: "", description: "" })}
|
||||
title="Create roadmap"
|
||||
aria-label="Create roadmap"
|
||||
data-testid="create-roadmap-btn"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{createForm.type === "roadmap" && (
|
||||
<CreateRoadmapForm
|
||||
@@ -2185,11 +2208,11 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<main className="roadmaps-view__main" aria-label="Roadmap content">
|
||||
{/* Main content */}
|
||||
<main className="roadmaps-view__main" aria-label="Roadmap content">
|
||||
{/* Mobile header when roadmap is selected */}
|
||||
{isMobile && effectiveSelectedRoadmapId && (
|
||||
<MobileRoadmapHeader
|
||||
@@ -2530,7 +2553,8 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Feature create form overlay */}
|
||||
{createForm.type === "feature" && createForm.parentId && (
|
||||
|
||||
@@ -55,6 +55,7 @@ vi.mock("lucide-react", () => ({
|
||||
ArrowLeft: (props: Record<string, unknown>) => <span data-testid="arrow-left-icon" {...props}>ArrowLeft</span>,
|
||||
ChevronLeft: (props: Record<string, unknown>) => <span data-testid="chevron-left-icon" {...props}>ChevronLeft</span>,
|
||||
ChevronUp: (props: Record<string, unknown>) => <span data-testid="chevron-up-icon" {...props}>ChevronUp</span>,
|
||||
Map: (props: Record<string, unknown>) => <span data-testid="map-icon" {...props}>Map</span>,
|
||||
}));
|
||||
|
||||
// Viewport mode mock helper
|
||||
@@ -142,7 +143,7 @@ describe("RoadmapsView", () => {
|
||||
render(<RoadmapsView addToast={mockAddToast} />);
|
||||
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user