FN-049: apply Appearance settings immediately in embedded Settings
Mirror Appearance drafts into mounted dashboard surfaces immediately while keeping SettingsModal as the persistence owner. - Add immediate setters for task routing, popup, cost badge, task-detail ordering, and chat layout settings. - Wire embedded and overlay Settings controls through the App shell with regression coverage. - Add the published-package fix changeset. Files changed: .changeset/fn-049-live-dashboard-settings.md | 7 +++ packages/dashboard/app/App.tsx | 17 +++++- packages/dashboard/app/components/AppModals.tsx | 19 ++++++ packages/dashboard/app/components/SettingsModal.tsx | 31 ++++++++++ packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx | 29 ++++++++++ packages/dashboard/app/components/dashboard/MainContent.tsx | 22 +++++++ packages/dashboard/app/components/dashboard/__tests__/MainContent.graph-popout.test.tsx | 67 +++++++++++++++++++++ packages/dashboard/app/components/dashboard/types.ts | 10 +++ packages/dashboard/app/components/settings/sections/AppearanceSection.tsx | 52 +++++++++++++---- packages/dashboard/app/components/settings/sections/__tests__/AppearanceSection.test.tsx | 41 ++++++++++++- packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts | 44 ++++++++++++++ packages/dashboard/app/hooks/useAppSettings.ts | 34 ++++++++++- packages/dashboard/app/task-modal-touch-resize-e2e-fixture.tsx | 2 +- 13 files changed, 359 insertions(+), 16 deletions(-) Fusion-Task-Id: FN-049 Fusion-Task-Lineage: 4140b47f-2f70-4c8b-ab47-2fc28672a2bb Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-049-live-dashboard-settings.md
Normal file
7
.changeset/fn-049-live-dashboard-settings.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Apply mounted Appearance settings immediately from either Settings view.
|
||||
category: fix
|
||||
dev: Mirrors chat layout, task routing, popup, cost badge, and task-detail ordering drafts into the App shell while SettingsModal remains the sole persistence writer.
|
||||
@@ -878,6 +878,11 @@ function AppInner() {
|
||||
goalsEnabled,
|
||||
setQuickChatButtonModeImmediate,
|
||||
setChatMessageLayoutImmediate,
|
||||
setOpenTasksInRightSidebarImmediate,
|
||||
setOpenMobileTasksInPopupImmediate,
|
||||
setTaskPopupsBoardListOnlyImmediate,
|
||||
setShowCostBadgeOnCardsImmediate,
|
||||
setTaskDetailChatFirstImmediate,
|
||||
setMobileNavPrimaryItemsImmediate,
|
||||
toggleAutoMerge,
|
||||
togglePlanAutoApprove,
|
||||
@@ -1666,6 +1671,12 @@ function AppInner() {
|
||||
setShadcnCustomColors,
|
||||
resolvedThemeMode,
|
||||
setQuickChatButtonModeImmediate,
|
||||
setChatMessageLayoutImmediate,
|
||||
setOpenTasksInRightSidebarImmediate,
|
||||
setOpenMobileTasksInPopupImmediate,
|
||||
setTaskPopupsBoardListOnlyImmediate,
|
||||
setShowCostBadgeOnCardsImmediate,
|
||||
setTaskDetailChatFirstImmediate,
|
||||
setMobileNavPrimaryItemsImmediate,
|
||||
reopenOnboardingWithNav,
|
||||
viewMode,
|
||||
@@ -1692,8 +1703,12 @@ function AppInner() {
|
||||
mergeStrategy,
|
||||
planAutoApproveEnabled,
|
||||
settingsLoaded,
|
||||
openTasksInRightSidebar,
|
||||
openMobileTasksInPopup,
|
||||
taskPopupsBoardListOnly,
|
||||
showCostBadgeOnCards,
|
||||
taskDetailChatFirst,
|
||||
chatMessageLayout,
|
||||
skillsEnabled,
|
||||
experimentalFeatures,
|
||||
setQuickChatOpen,
|
||||
@@ -2265,7 +2280,7 @@ function AppInner() {
|
||||
onSubtaskBreakdown={subtaskBreakdownEnabled ? openSubtaskBreakdownWithNav : undefined}
|
||||
taskOperations={{ moveTask, deleteTask, mergeTask, archiveTask, revertTask, retryTask, pauseTask, unpauseTask, bypassReview, resetTask, duplicateTask }}
|
||||
deepLink={{ handleDetailClose }}
|
||||
settings={{ prAuthAvailable, autoMerge, taskDetailChatFirst, chatMessageLayout, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors, resolvedThemeMode, setThemeMode, setColorTheme, setDashboardFontScalePct, setShadcnCustomColors, setQuickChatButtonModeImmediate, setChatMessageLayoutImmediate, setMobileNavPrimaryItemsImmediate }}
|
||||
settings={{ prAuthAvailable, autoMerge, openTasksInRightSidebar, openMobileTasksInPopup, taskPopupsBoardListOnly, showCostBadgeOnCards, taskDetailChatFirst, chatMessageLayout, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors, resolvedThemeMode, setThemeMode, setColorTheme, setDashboardFontScalePct, setShadcnCustomColors, setQuickChatButtonModeImmediate, setChatMessageLayoutImmediate, setOpenTasksInRightSidebarImmediate, setOpenMobileTasksInPopupImmediate, setTaskPopupsBoardListOnlyImmediate, setShowCostBadgeOnCardsImmediate, setTaskDetailChatFirstImmediate, setMobileNavPrimaryItemsImmediate }}
|
||||
onSettingsClose={handleSettingsCloseWithNav}
|
||||
onReopenOnboarding={reopenOnboardingWithNav}
|
||||
onOpenApprovals={(_approvalId) => handleTaskViewChange("mailbox")}
|
||||
|
||||
@@ -85,6 +85,10 @@ interface AppModalsProps {
|
||||
settings: {
|
||||
prAuthAvailable: boolean;
|
||||
autoMerge: boolean;
|
||||
openTasksInRightSidebar: boolean;
|
||||
openMobileTasksInPopup: boolean;
|
||||
taskPopupsBoardListOnly: boolean;
|
||||
showCostBadgeOnCards: boolean;
|
||||
taskDetailChatFirst: boolean;
|
||||
chatMessageLayout: ChatMessageLayout;
|
||||
themeMode: ThemeMode;
|
||||
@@ -98,6 +102,11 @@ interface AppModalsProps {
|
||||
setShadcnCustomColors: (colors: Record<string, string>) => void;
|
||||
setQuickChatButtonModeImmediate: (mode: "floating" | "footer" | "off") => void;
|
||||
setChatMessageLayoutImmediate: (layout: ChatMessageLayout) => void;
|
||||
setOpenTasksInRightSidebarImmediate: (enabled: boolean) => void;
|
||||
setOpenMobileTasksInPopupImmediate: (enabled: boolean) => void;
|
||||
setTaskPopupsBoardListOnlyImmediate: (enabled: boolean) => void;
|
||||
setShowCostBadgeOnCardsImmediate: (enabled: boolean) => void;
|
||||
setTaskDetailChatFirstImmediate: (enabled: boolean) => void;
|
||||
setMobileNavPrimaryItemsImmediate: (items: string[]) => void;
|
||||
};
|
||||
/** Optional override for the settings modal close handler. When provided, this is called instead of modalManager.closeSettings. */
|
||||
@@ -386,6 +395,16 @@ export function AppModals({
|
||||
onQuickChatButtonModeChange={settings.setQuickChatButtonModeImmediate}
|
||||
chatMessageLayout={settings.chatMessageLayout}
|
||||
onChatMessageLayoutChange={settings.setChatMessageLayoutImmediate}
|
||||
openTasksInRightSidebar={settings.openTasksInRightSidebar}
|
||||
onOpenTasksInRightSidebarChange={settings.setOpenTasksInRightSidebarImmediate}
|
||||
openMobileTasksInPopup={settings.openMobileTasksInPopup}
|
||||
onOpenMobileTasksInPopupChange={settings.setOpenMobileTasksInPopupImmediate}
|
||||
taskPopupsBoardListOnly={settings.taskPopupsBoardListOnly}
|
||||
onTaskPopupsBoardListOnlyChange={settings.setTaskPopupsBoardListOnlyImmediate}
|
||||
showCostBadgeOnCards={settings.showCostBadgeOnCards}
|
||||
onShowCostBadgeOnCardsChange={settings.setShowCostBadgeOnCardsImmediate}
|
||||
taskDetailChatFirst={settings.taskDetailChatFirst}
|
||||
onTaskDetailChatFirstChange={settings.setTaskDetailChatFirstImmediate}
|
||||
onMobileNavPrimaryItemsChange={settings.setMobileNavPrimaryItemsImmediate}
|
||||
onReopenOnboarding={onReopenOnboarding}
|
||||
onOpenApprovals={onOpenApprovals}
|
||||
|
||||
@@ -665,6 +665,17 @@ interface SettingsModalProps {
|
||||
/** Mirrors the pending project conversation layout into mounted chat surfaces immediately. */
|
||||
chatMessageLayout?: ChatMessageLayout;
|
||||
onChatMessageLayoutChange?: (layout: ChatMessageLayout) => void;
|
||||
/** Current App-shell values and optimistic callbacks for mounted Appearance consumers. */
|
||||
openTasksInRightSidebar?: boolean;
|
||||
onOpenTasksInRightSidebarChange?: (enabled: boolean) => void;
|
||||
openMobileTasksInPopup?: boolean;
|
||||
onOpenMobileTasksInPopupChange?: (enabled: boolean) => void;
|
||||
taskPopupsBoardListOnly?: boolean;
|
||||
onTaskPopupsBoardListOnlyChange?: (enabled: boolean) => void;
|
||||
showCostBadgeOnCards?: boolean;
|
||||
onShowCostBadgeOnCardsChange?: (enabled: boolean) => void;
|
||||
taskDetailChatFirst?: boolean;
|
||||
onTaskDetailChatFirstChange?: (enabled: boolean) => void;
|
||||
/** Mirrors pending mobile quick-action changes into the app shell immediately. */
|
||||
onMobileNavPrimaryItemsChange?: (items: string[]) => void;
|
||||
/** Optional callback when user wants to reopen the onboarding guide */
|
||||
@@ -932,6 +943,16 @@ export function SettingsModal({
|
||||
onQuickChatButtonModeChange,
|
||||
chatMessageLayout = "bubbles",
|
||||
onChatMessageLayoutChange,
|
||||
openTasksInRightSidebar,
|
||||
onOpenTasksInRightSidebarChange,
|
||||
openMobileTasksInPopup,
|
||||
onOpenMobileTasksInPopupChange,
|
||||
taskPopupsBoardListOnly,
|
||||
onTaskPopupsBoardListOnlyChange,
|
||||
showCostBadgeOnCards,
|
||||
onShowCostBadgeOnCardsChange,
|
||||
taskDetailChatFirst,
|
||||
onTaskDetailChatFirstChange,
|
||||
onMobileNavPrimaryItemsChange,
|
||||
onReopenOnboarding,
|
||||
onOpenApprovals,
|
||||
@@ -4214,6 +4235,16 @@ export function SettingsModal({
|
||||
onShadcnCustomColorsChange={onShadcnCustomColorsChange}
|
||||
chatMessageLayout={chatMessageLayout}
|
||||
onChatMessageLayoutChange={onChatMessageLayoutChange}
|
||||
openTasksInRightSidebar={openTasksInRightSidebar}
|
||||
onOpenTasksInRightSidebarChange={onOpenTasksInRightSidebarChange}
|
||||
openMobileTasksInPopup={openMobileTasksInPopup}
|
||||
onOpenMobileTasksInPopupChange={onOpenMobileTasksInPopupChange}
|
||||
taskPopupsBoardListOnly={taskPopupsBoardListOnly}
|
||||
onTaskPopupsBoardListOnlyChange={onTaskPopupsBoardListOnlyChange}
|
||||
showCostBadgeOnCards={showCostBadgeOnCards}
|
||||
onShowCostBadgeOnCardsChange={onShowCostBadgeOnCardsChange}
|
||||
taskDetailChatFirst={taskDetailChatFirst}
|
||||
onTaskDetailChatFirstChange={onTaskDetailChatFirstChange}
|
||||
sessionBannersHidden={sessionBannersHidden}
|
||||
setSessionBannersHidden={setSessionBannersHidden}
|
||||
/>
|
||||
|
||||
@@ -263,6 +263,35 @@ describe("SettingsModal", () => {
|
||||
expect(mockUpdateGlobalSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mirrors mounted Appearance controls through the modal while keeping one persistence path", async () => {
|
||||
const callbacks = {
|
||||
onOpenTasksInRightSidebarChange: vi.fn(),
|
||||
onOpenMobileTasksInPopupChange: vi.fn(),
|
||||
onTaskPopupsBoardListOnlyChange: vi.fn(),
|
||||
onShowCostBadgeOnCardsChange: vi.fn(),
|
||||
onTaskDetailChatFirstChange: vi.fn(),
|
||||
};
|
||||
renderModal({ initialSection: "appearance", ...callbacks });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Open tasks in the right sidebar"));
|
||||
fireEvent.click(screen.getByLabelText("Open tasks as popups"));
|
||||
fireEvent.click(screen.getByLabelText("Keep task popups on the view where they were opened"));
|
||||
fireEvent.click(screen.getByLabelText("Show cost badges on task cards"));
|
||||
fireEvent.click(screen.getByLabelText("Open task details with Chat first"));
|
||||
|
||||
expect(callbacks.onOpenTasksInRightSidebarChange).toHaveBeenCalledWith(true);
|
||||
expect(callbacks.onOpenMobileTasksInPopupChange).toHaveBeenCalledWith(true);
|
||||
expect(callbacks.onTaskPopupsBoardListOnlyChange).toHaveBeenCalledWith(true);
|
||||
expect(callbacks.onShowCostBadgeOnCardsChange).toHaveBeenCalledWith(true);
|
||||
expect(callbacks.onTaskDetailChatFirstChange).toHaveBeenCalledWith(true);
|
||||
|
||||
vi.useFakeTimers();
|
||||
await flushSettingsAutoSave();
|
||||
vi.useRealTimers();
|
||||
expect(mockUpdateGlobalSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders recommendation mailbox notices enabled by default and persists disabling it", async () => {
|
||||
renderModal({ initialSection: "general" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
@@ -60,6 +60,12 @@ export function MainContent({
|
||||
setShadcnCustomColors,
|
||||
resolvedThemeMode,
|
||||
setQuickChatButtonModeImmediate,
|
||||
setChatMessageLayoutImmediate,
|
||||
setOpenTasksInRightSidebarImmediate,
|
||||
setOpenMobileTasksInPopupImmediate,
|
||||
setTaskPopupsBoardListOnlyImmediate,
|
||||
setShowCostBadgeOnCardsImmediate,
|
||||
setTaskDetailChatFirstImmediate,
|
||||
setMobileNavPrimaryItemsImmediate,
|
||||
reopenOnboardingWithNav,
|
||||
viewMode,
|
||||
@@ -86,8 +92,12 @@ export function MainContent({
|
||||
mergeStrategy,
|
||||
planAutoApproveEnabled,
|
||||
settingsLoaded,
|
||||
openTasksInRightSidebar,
|
||||
openMobileTasksInPopup,
|
||||
taskPopupsBoardListOnly,
|
||||
showCostBadgeOnCards,
|
||||
taskDetailChatFirst,
|
||||
chatMessageLayout,
|
||||
skillsEnabled,
|
||||
experimentalFeatures,
|
||||
setQuickChatOpen,
|
||||
@@ -328,6 +338,18 @@ export function MainContent({
|
||||
onDashboardFontScaleChange={setDashboardFontScalePct}
|
||||
onShadcnCustomColorsChange={setShadcnCustomColors}
|
||||
onQuickChatButtonModeChange={setQuickChatButtonModeImmediate}
|
||||
chatMessageLayout={chatMessageLayout}
|
||||
onChatMessageLayoutChange={setChatMessageLayoutImmediate}
|
||||
openTasksInRightSidebar={openTasksInRightSidebar}
|
||||
onOpenTasksInRightSidebarChange={setOpenTasksInRightSidebarImmediate}
|
||||
openMobileTasksInPopup={openMobileTasksInPopup}
|
||||
onOpenMobileTasksInPopupChange={setOpenMobileTasksInPopupImmediate}
|
||||
taskPopupsBoardListOnly={taskPopupsBoardListOnly}
|
||||
onTaskPopupsBoardListOnlyChange={setTaskPopupsBoardListOnlyImmediate}
|
||||
showCostBadgeOnCards={showCostBadgeOnCards}
|
||||
onShowCostBadgeOnCardsChange={setShowCostBadgeOnCardsImmediate}
|
||||
taskDetailChatFirst={taskDetailChatFirst}
|
||||
onTaskDetailChatFirstChange={setTaskDetailChatFirstImmediate}
|
||||
onMobileNavPrimaryItemsChange={setMobileNavPrimaryItemsImmediate}
|
||||
onReopenOnboarding={reopenOnboardingWithNav}
|
||||
onOpenApprovals={() => handleChangeTaskView("mailbox")}
|
||||
|
||||
@@ -65,6 +65,13 @@ const LazyStub = lazy(async () => ({ default: () => null }));
|
||||
const LazySettingsCloseStub = lazy(async () => ({
|
||||
default: ({ onClose }: { onClose: () => void }) => <button type="button" onClick={onClose}>Close settings view</button>,
|
||||
}));
|
||||
let embeddedSettingsProps: Record<string, unknown> | undefined;
|
||||
const LazySettingsBridgeStub = lazy(async () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
embeddedSettingsProps = props;
|
||||
return <div>Embedded settings bridge</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
function mainContentProps(overrides: Partial<MainContentProps> = {}): MainContentProps {
|
||||
return {
|
||||
@@ -94,6 +101,12 @@ function mainContentProps(overrides: Partial<MainContentProps> = {}): MainConten
|
||||
setShadcnCustomColors: vi.fn(),
|
||||
resolvedThemeMode: "light",
|
||||
setQuickChatButtonModeImmediate: vi.fn(),
|
||||
setChatMessageLayoutImmediate: vi.fn(),
|
||||
setOpenTasksInRightSidebarImmediate: vi.fn(),
|
||||
setOpenMobileTasksInPopupImmediate: vi.fn(),
|
||||
setTaskPopupsBoardListOnlyImmediate: vi.fn(),
|
||||
setShowCostBadgeOnCardsImmediate: vi.fn(),
|
||||
setTaskDetailChatFirstImmediate: vi.fn(),
|
||||
reopenOnboardingWithNav: vi.fn(),
|
||||
viewMode: "project",
|
||||
projects: [],
|
||||
@@ -136,6 +149,12 @@ function mainContentProps(overrides: Partial<MainContentProps> = {}): MainConten
|
||||
autoMerge: true,
|
||||
mergeStrategy: "direct",
|
||||
settingsLoaded: true,
|
||||
openTasksInRightSidebar: false,
|
||||
openMobileTasksInPopup: false,
|
||||
taskPopupsBoardListOnly: true,
|
||||
showCostBadgeOnCards: false,
|
||||
taskDetailChatFirst: false,
|
||||
chatMessageLayout: "bubbles",
|
||||
skillsEnabled: true,
|
||||
experimentalFeatures: {},
|
||||
setQuickChatOpen: vi.fn(),
|
||||
@@ -260,6 +279,54 @@ describe("MainContent graph task pop-out wiring", () => {
|
||||
expect(refreshAppSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forwards every live Appearance value and callback to embedded Settings", async () => {
|
||||
embeddedSettingsProps = undefined;
|
||||
const setters = {
|
||||
setChatMessageLayoutImmediate: vi.fn(),
|
||||
setOpenTasksInRightSidebarImmediate: vi.fn(),
|
||||
setOpenMobileTasksInPopupImmediate: vi.fn(),
|
||||
setTaskPopupsBoardListOnlyImmediate: vi.fn(),
|
||||
setShowCostBadgeOnCardsImmediate: vi.fn(),
|
||||
setTaskDetailChatFirstImmediate: vi.fn(),
|
||||
};
|
||||
|
||||
render(<MainContent {...mainContentProps({
|
||||
taskView: "settings",
|
||||
chatMessageLayout: "full-width",
|
||||
openTasksInRightSidebar: true,
|
||||
openMobileTasksInPopup: true,
|
||||
taskPopupsBoardListOnly: false,
|
||||
showCostBadgeOnCards: true,
|
||||
taskDetailChatFirst: true,
|
||||
...setters,
|
||||
_SettingsView: LazySettingsBridgeStub as MainContentProps["_SettingsView"],
|
||||
})} />);
|
||||
|
||||
await screen.findByText("Embedded settings bridge");
|
||||
expect(embeddedSettingsProps).toMatchObject({
|
||||
chatMessageLayout: "full-width",
|
||||
openTasksInRightSidebar: true,
|
||||
openMobileTasksInPopup: true,
|
||||
taskPopupsBoardListOnly: false,
|
||||
showCostBadgeOnCards: true,
|
||||
taskDetailChatFirst: true,
|
||||
});
|
||||
|
||||
(embeddedSettingsProps?.onChatMessageLayoutChange as (value: "bubbles" | "full-width") => void)("bubbles");
|
||||
(embeddedSettingsProps?.onOpenTasksInRightSidebarChange as (value: boolean) => void)(false);
|
||||
(embeddedSettingsProps?.onOpenMobileTasksInPopupChange as (value: boolean) => void)(false);
|
||||
(embeddedSettingsProps?.onTaskPopupsBoardListOnlyChange as (value: boolean) => void)(true);
|
||||
(embeddedSettingsProps?.onShowCostBadgeOnCardsChange as (value: boolean) => void)(false);
|
||||
(embeddedSettingsProps?.onTaskDetailChatFirstChange as (value: boolean) => void)(false);
|
||||
|
||||
expect(setters.setChatMessageLayoutImmediate).toHaveBeenCalledWith("bubbles");
|
||||
expect(setters.setOpenTasksInRightSidebarImmediate).toHaveBeenCalledWith(false);
|
||||
expect(setters.setOpenMobileTasksInPopupImmediate).toHaveBeenCalledWith(false);
|
||||
expect(setters.setTaskPopupsBoardListOnlyImmediate).toHaveBeenCalledWith(true);
|
||||
expect(setters.setShowCostBadgeOnCardsImmediate).toHaveBeenCalledWith(false);
|
||||
expect(setters.setTaskDetailChatFirstImmediate).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("routes dependency-graph bridge and rendered task-card opens to the shared pop-out", () => {
|
||||
hostContexts.length = 0;
|
||||
const openDetailTask = vi.fn();
|
||||
|
||||
@@ -134,8 +134,18 @@ export interface MainContentProps {
|
||||
mergeStrategy: string;
|
||||
planAutoApproveEnabled: boolean;
|
||||
settingsLoaded: boolean;
|
||||
openTasksInRightSidebar: boolean;
|
||||
openMobileTasksInPopup: boolean;
|
||||
taskPopupsBoardListOnly: boolean;
|
||||
showCostBadgeOnCards: boolean;
|
||||
taskDetailChatFirst: boolean;
|
||||
chatMessageLayout: "bubbles" | "full-width";
|
||||
setOpenTasksInRightSidebarImmediate: (enabled: boolean) => void;
|
||||
setOpenMobileTasksInPopupImmediate: (enabled: boolean) => void;
|
||||
setTaskPopupsBoardListOnlyImmediate: (enabled: boolean) => void;
|
||||
setShowCostBadgeOnCardsImmediate: (enabled: boolean) => void;
|
||||
setTaskDetailChatFirstImmediate: (enabled: boolean) => void;
|
||||
setChatMessageLayoutImmediate: (layout: "bubbles" | "full-width") => void;
|
||||
skillsEnabled: boolean;
|
||||
experimentalFeatures: Record<string, boolean>;
|
||||
setQuickChatOpen: Dispatch<SetStateAction<boolean>>;
|
||||
|
||||
@@ -18,6 +18,16 @@ export interface AppearanceSectionProps extends SectionBaseProps {
|
||||
onShadcnCustomColorsChange?: (colors: Record<string, string>) => void;
|
||||
chatMessageLayout?: ChatMessageLayout;
|
||||
onChatMessageLayoutChange?: (layout: ChatMessageLayout) => void;
|
||||
openTasksInRightSidebar?: boolean;
|
||||
onOpenTasksInRightSidebarChange?: (enabled: boolean) => void;
|
||||
openMobileTasksInPopup?: boolean;
|
||||
onOpenMobileTasksInPopupChange?: (enabled: boolean) => void;
|
||||
taskPopupsBoardListOnly?: boolean;
|
||||
onTaskPopupsBoardListOnlyChange?: (enabled: boolean) => void;
|
||||
showCostBadgeOnCards?: boolean;
|
||||
onShowCostBadgeOnCardsChange?: (enabled: boolean) => void;
|
||||
taskDetailChatFirst?: boolean;
|
||||
onTaskDetailChatFirstChange?: (enabled: boolean) => void;
|
||||
sessionBannersHidden: boolean;
|
||||
setSessionBannersHidden: (hidden: boolean) => void;
|
||||
}
|
||||
@@ -29,7 +39,7 @@ Rows render through the shared settings primitives rather than hand-rolled `form
|
||||
FNXC:SettingsScope 2026-07-15-17:35:
|
||||
Scope badges are per-row because this section genuinely mixes authority levels: theme, color, and font scale are global (DEFAULT_GLOBAL_SETTINGS), while every task-presentation toggle below is project-scoped (DEFAULT_PROJECT_SETTINGS). The nav labels the whole section "global", which is true only of the theme controls, so the badges are what tell an operator which of these travels between projects.
|
||||
*/
|
||||
export function AppearanceSection({ form, setForm, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors = {}, resolvedThemeMode, onThemeModeChange, onColorThemeChange, onDashboardFontScaleChange, onShadcnCustomColorsChange, chatMessageLayout = "bubbles", onChatMessageLayoutChange, sessionBannersHidden, setSessionBannersHidden, }: AppearanceSectionProps) {
|
||||
export function AppearanceSection({ form, setForm, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors = {}, resolvedThemeMode, onThemeModeChange, onColorThemeChange, onDashboardFontScaleChange, onShadcnCustomColorsChange, chatMessageLayout = "bubbles", onChatMessageLayoutChange, openTasksInRightSidebar, onOpenTasksInRightSidebarChange, openMobileTasksInPopup, onOpenMobileTasksInPopupChange, taskPopupsBoardListOnly, onTaskPopupsBoardListOnlyChange, showCostBadgeOnCards, onShowCostBadgeOnCardsChange, taskDetailChatFirst, onTaskDetailChatFirstChange, sessionBannersHidden, setSessionBannersHidden, }: AppearanceSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
return (<>
|
||||
<h4 className="settings-section-heading">{t("settings.appearance.title", "Appearance")}</h4>
|
||||
@@ -72,8 +82,12 @@ export function AppearanceSection({ form, setForm, themeMode, colorTheme, dashbo
|
||||
help: t("settings.appearance.openTasksInRightSidebarHelp", "When enabled, board task cards open detail in the right sidebar when it is available; mobile and hidden-sidebar states keep the full task panel. Default: disabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.openTasksInRightSidebar === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, openTasksInRightSidebar: v === true }))}
|
||||
value={form.openTasksInRightSidebar ?? openTasksInRightSidebar === true}
|
||||
onChange={(v) => {
|
||||
const enabled = v === true;
|
||||
setForm((f) => ({ ...f, openTasksInRightSidebar: enabled }));
|
||||
onOpenTasksInRightSidebarChange?.(enabled);
|
||||
}}
|
||||
/>
|
||||
{/* FNXC:MobileTaskPopups 2026-07-21-00:00 (FN-8478): Keep the stored openMobileTasksInPopup key for compatibility and explain that board-card deep-tab chips now use the same popup routing, preserving the board behind Changes, Retries, or Workflow detail. */}
|
||||
<SettingsToggleRow
|
||||
@@ -83,8 +97,12 @@ export function AppearanceSection({ form, setForm, themeMode, colorTheme, dashbo
|
||||
help: t("settings.appearance.openMobileTasksInPopupHelp", "When enabled, board task-card clicks including Changes, Retries, and Workflow chips, plus ordinary List row/card and right-dock Tasks-list clicks, open the existing movable task popup so the board or list remains visible. Other task opens keep their current behavior. Default: disabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.openMobileTasksInPopup === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, openMobileTasksInPopup: v === true }))}
|
||||
value={form.openMobileTasksInPopup ?? openMobileTasksInPopup === true}
|
||||
onChange={(v) => {
|
||||
const enabled = v === true;
|
||||
setForm((f) => ({ ...f, openMobileTasksInPopup: enabled }));
|
||||
onOpenMobileTasksInPopupChange?.(enabled);
|
||||
}}
|
||||
/>
|
||||
{/* FNXC:TaskPopupViewGating 2026-07-15-15:20: FN-8016 scopes task popups to their opening dashboard view by default. Operators may explicitly disable it for legacy globally shared popups; hidden scoped entries retain geometry and reopen on return. */}
|
||||
<SettingsToggleRow
|
||||
@@ -94,8 +112,12 @@ export function AppearanceSection({ form, setForm, themeMode, colorTheme, dashbo
|
||||
help: t("settings.appearance.taskPopupsBoardListOnlyHelp", "When enabled, each open task-detail popup appears only on the view where it was opened. Switching views hides it without closing; returning restores it in the same position. Default: enabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.taskPopupsBoardListOnly === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, taskPopupsBoardListOnly: v === true }))}
|
||||
value={form.taskPopupsBoardListOnly ?? taskPopupsBoardListOnly === true}
|
||||
onChange={(v) => {
|
||||
const enabled = v === true;
|
||||
setForm((f) => ({ ...f, taskPopupsBoardListOnly: enabled }));
|
||||
onTaskPopupsBoardListOnlyChange?.(enabled);
|
||||
}}
|
||||
/>
|
||||
{/* FNXC:TaskCardCostBadge 2026-07-11-12:15: This project setting is opt-in because board cards are already dense; when enabled, only tasks with recorded positive token usage render a read-time derived spend badge. */}
|
||||
<SettingsToggleRow
|
||||
@@ -105,8 +127,12 @@ export function AppearanceSection({ form, setForm, themeMode, colorTheme, dashbo
|
||||
help: t("settings.appearance.showCostBadgeOnCardsHelp", "Default: disabled. When enabled, board cards show derived model cost next to execution time; unavailable pricing displays — and tasks without token usage show no badge."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.showCostBadgeOnCards === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, showCostBadgeOnCards: v === true }))}
|
||||
value={form.showCostBadgeOnCards ?? showCostBadgeOnCards === true}
|
||||
onChange={(v) => {
|
||||
const enabled = v === true;
|
||||
setForm((f) => ({ ...f, showCostBadgeOnCards: enabled }));
|
||||
onShowCostBadgeOnCardsChange?.(enabled);
|
||||
}}
|
||||
/>
|
||||
{/* FNXC:TaskDetailActivityFirst 2026-06-30-23:59: The project setting is opt-in because task details now default to Activity-first; explicit Activity/Chat/Logs links keep their destination regardless of this checkbox. */}
|
||||
<SettingsToggleRow
|
||||
@@ -116,8 +142,12 @@ export function AppearanceSection({ form, setForm, themeMode, colorTheme, dashbo
|
||||
help: t("settings.appearance.taskDetailChatFirstHelp", "Off by default: task details list Activity first and omitted non-done opens land on Activity. Turn on to restore Chat-first order/default; explicit Chat links still work either way."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.taskDetailChatFirst === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, taskDetailChatFirst: v === true }))}
|
||||
value={form.taskDetailChatFirst ?? taskDetailChatFirst === true}
|
||||
onChange={(v) => {
|
||||
const enabled = v === true;
|
||||
setForm((f) => ({ ...f, taskDetailChatFirst: enabled }));
|
||||
onTaskDetailChatFirstChange?.(enabled);
|
||||
}}
|
||||
/>
|
||||
{/*
|
||||
FNXC:SettingsScope 2026-07-15-17:35:
|
||||
|
||||
@@ -13,6 +13,11 @@ vi.mock("../../LanguageSelector", () => ({
|
||||
}));
|
||||
|
||||
function renderAppearanceSection(formOverrides: Partial<Settings> = {}, onChatMessageLayoutChange = vi.fn()) {
|
||||
const onOpenTasksInRightSidebarChange = vi.fn();
|
||||
const onOpenMobileTasksInPopupChange = vi.fn();
|
||||
const onTaskPopupsBoardListOnlyChange = vi.fn();
|
||||
const onShowCostBadgeOnCardsChange = vi.fn();
|
||||
const onTaskDetailChatFirstChange = vi.fn();
|
||||
let form: SettingsFormState = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
@@ -40,12 +45,30 @@ function renderAppearanceSection(formOverrides: Partial<Settings> = {}, onChatMe
|
||||
dashboardFontScalePct={100}
|
||||
chatMessageLayout={form.chatMessageLayout}
|
||||
onChatMessageLayoutChange={onChatMessageLayoutChange}
|
||||
openTasksInRightSidebar={form.openTasksInRightSidebar}
|
||||
onOpenTasksInRightSidebarChange={onOpenTasksInRightSidebarChange}
|
||||
openMobileTasksInPopup={form.openMobileTasksInPopup}
|
||||
onOpenMobileTasksInPopupChange={onOpenMobileTasksInPopupChange}
|
||||
taskPopupsBoardListOnly={form.taskPopupsBoardListOnly}
|
||||
onTaskPopupsBoardListOnlyChange={onTaskPopupsBoardListOnlyChange}
|
||||
showCostBadgeOnCards={form.showCostBadgeOnCards}
|
||||
onShowCostBadgeOnCardsChange={onShowCostBadgeOnCardsChange}
|
||||
taskDetailChatFirst={form.taskDetailChatFirst}
|
||||
onTaskDetailChatFirstChange={onTaskDetailChatFirstChange}
|
||||
sessionBannersHidden={false}
|
||||
setSessionBannersHidden={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
return { setForm, getForm: () => form };
|
||||
return {
|
||||
setForm,
|
||||
getForm: () => form,
|
||||
onOpenTasksInRightSidebarChange,
|
||||
onOpenMobileTasksInPopupChange,
|
||||
onTaskPopupsBoardListOnlyChange,
|
||||
onShowCostBadgeOnCardsChange,
|
||||
onTaskDetailChatFirstChange,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AppearanceSection", () => {
|
||||
@@ -78,6 +101,22 @@ describe("AppearanceSection", () => {
|
||||
expect(getForm().openTasksInRightSidebar).toBe(true);
|
||||
});
|
||||
|
||||
it("mirrors every mounted Appearance toggle to its matching live callback", () => {
|
||||
const callbacks = renderAppearanceSection();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Open tasks in the right sidebar"));
|
||||
fireEvent.click(screen.getByLabelText("Open tasks as popups"));
|
||||
fireEvent.click(screen.getByLabelText("Keep task popups on the view where they were opened"));
|
||||
fireEvent.click(screen.getByLabelText("Show cost badges on task cards"));
|
||||
fireEvent.click(screen.getByLabelText("Open task details with Chat first"));
|
||||
|
||||
expect(callbacks.onOpenTasksInRightSidebarChange).toHaveBeenCalledWith(true);
|
||||
expect(callbacks.onOpenMobileTasksInPopupChange).toHaveBeenCalledWith(true);
|
||||
expect(callbacks.onTaskPopupsBoardListOnlyChange).toHaveBeenCalledWith(false);
|
||||
expect(callbacks.onShowCostBadgeOnCardsChange).toHaveBeenCalledWith(true);
|
||||
expect(callbacks.onTaskDetailChatFirstChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("reflects a persisted enabled value", () => {
|
||||
renderAppearanceSection({ openTasksInRightSidebar: true });
|
||||
|
||||
|
||||
@@ -58,6 +58,50 @@ describe("useAppSettings", () => {
|
||||
expect(result.current.chatMessageLayout).toBe(expectedLayout);
|
||||
});
|
||||
|
||||
it("updates every mounted Appearance consumer synchronously without fetching", async () => {
|
||||
const { result } = renderHook(() => useAppSettings("proj_123"));
|
||||
|
||||
await waitFor(() => expect(result.current.settingsLoaded).toBe(true));
|
||||
const settingsFetchesBefore = mockFetchSettings.mock.calls.length;
|
||||
const configFetchesBefore = mockFetchConfig.mock.calls.length;
|
||||
|
||||
act(() => {
|
||||
result.current.setChatMessageLayoutImmediate("full-width");
|
||||
result.current.setOpenTasksInRightSidebarImmediate(true);
|
||||
result.current.setOpenMobileTasksInPopupImmediate(true);
|
||||
result.current.setTaskPopupsBoardListOnlyImmediate(false);
|
||||
result.current.setShowCostBadgeOnCardsImmediate(true);
|
||||
result.current.setTaskDetailChatFirstImmediate(true);
|
||||
});
|
||||
|
||||
expect(result.current.chatMessageLayout).toBe("full-width");
|
||||
expect(result.current.openTasksInRightSidebar).toBe(true);
|
||||
expect(result.current.openMobileTasksInPopup).toBe(true);
|
||||
expect(result.current.taskPopupsBoardListOnly).toBe(false);
|
||||
expect(result.current.showCostBadgeOnCards).toBe(true);
|
||||
expect(result.current.taskDetailChatFirst).toBe(true);
|
||||
expect(mockFetchSettings).toHaveBeenCalledTimes(settingsFetchesBefore);
|
||||
expect(mockFetchConfig).toHaveBeenCalledTimes(configFetchesBefore);
|
||||
|
||||
act(() => {
|
||||
result.current.setChatMessageLayoutImmediate("bubbles");
|
||||
result.current.setOpenTasksInRightSidebarImmediate(false);
|
||||
result.current.setOpenMobileTasksInPopupImmediate(false);
|
||||
result.current.setTaskPopupsBoardListOnlyImmediate(true);
|
||||
result.current.setShowCostBadgeOnCardsImmediate(false);
|
||||
result.current.setTaskDetailChatFirstImmediate(false);
|
||||
});
|
||||
|
||||
expect(result.current.chatMessageLayout).toBe("bubbles");
|
||||
expect(result.current.openTasksInRightSidebar).toBe(false);
|
||||
expect(result.current.openMobileTasksInPopup).toBe(false);
|
||||
expect(result.current.taskPopupsBoardListOnly).toBe(true);
|
||||
expect(result.current.showCostBadgeOnCards).toBe(false);
|
||||
expect(result.current.taskDetailChatFirst).toBe(false);
|
||||
expect(mockFetchSettings).toHaveBeenCalledTimes(settingsFetchesBefore);
|
||||
expect(mockFetchConfig).toHaveBeenCalledTimes(configFetchesBefore);
|
||||
});
|
||||
|
||||
it("resets chat message layout to bubbles while a new project hydrates", async () => {
|
||||
mockFetchSettings
|
||||
.mockResolvedValueOnce({ chatMessageLayout: "full-width" } as never)
|
||||
|
||||
@@ -68,6 +68,11 @@ export interface UseAppSettingsResult {
|
||||
toggleShowQuickChatFAB: () => Promise<void>;
|
||||
setQuickChatButtonModeImmediate: (mode: QuickChatButtonMode) => void;
|
||||
setChatMessageLayoutImmediate: (layout: ChatMessageLayout) => void;
|
||||
setOpenTasksInRightSidebarImmediate: (enabled: boolean) => void;
|
||||
setOpenMobileTasksInPopupImmediate: (enabled: boolean) => void;
|
||||
setTaskPopupsBoardListOnlyImmediate: (enabled: boolean) => void;
|
||||
setShowCostBadgeOnCardsImmediate: (enabled: boolean) => void;
|
||||
setTaskDetailChatFirstImmediate: (enabled: boolean) => void;
|
||||
setMobileNavPrimaryItemsImmediate: (items: string[]) => void;
|
||||
toggleAutoReloadOnVersionChange: () => Promise<void>;
|
||||
/** Re-fetches settings from the backend to pick up changes made externally (e.g., by SettingsModal). */
|
||||
@@ -338,12 +343,32 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
||||
|
||||
const setChatMessageLayoutImmediate = useCallback((layout: ChatMessageLayout) => {
|
||||
/*
|
||||
FNXC:ChatMessageLayout 2026-08-18-20:27:
|
||||
The Appearance draft must update already-mounted ChatView and task-chat surfaces immediately; SettingsModal remains the sole persistence owner.
|
||||
FNXC:LiveAppearanceSettings 2026-08-19-18:07:
|
||||
Both overlay and embedded Settings must mirror every mounted Appearance control into the App shell during its input event. These setters intentionally avoid persistence: SettingsModal remains the only debounced writer and its reconciliation remains authoritative.
|
||||
*/
|
||||
setChatMessageLayout(normalizeChatMessageLayout(layout));
|
||||
}, []);
|
||||
|
||||
const setOpenTasksInRightSidebarImmediate = useCallback((enabled: boolean) => {
|
||||
setOpenTasksInRightSidebar(enabled === true);
|
||||
}, []);
|
||||
|
||||
const setOpenMobileTasksInPopupImmediate = useCallback((enabled: boolean) => {
|
||||
setOpenMobileTasksInPopup(enabled === true);
|
||||
}, []);
|
||||
|
||||
const setTaskPopupsBoardListOnlyImmediate = useCallback((enabled: boolean) => {
|
||||
setTaskPopupsBoardListOnly(enabled === true);
|
||||
}, []);
|
||||
|
||||
const setShowCostBadgeOnCardsImmediate = useCallback((enabled: boolean) => {
|
||||
setShowCostBadgeOnCards(enabled === true);
|
||||
}, []);
|
||||
|
||||
const setTaskDetailChatFirstImmediate = useCallback((enabled: boolean) => {
|
||||
setTaskDetailChatFirst(enabled === true);
|
||||
}, []);
|
||||
|
||||
/*
|
||||
FNXC:Navigation 2026-07-17-00:00:
|
||||
The settings draft previews mobile quick-action order and membership in the app shell before Save;
|
||||
@@ -413,6 +438,11 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
||||
toggleShowQuickChatFAB,
|
||||
setQuickChatButtonModeImmediate,
|
||||
setChatMessageLayoutImmediate,
|
||||
setOpenTasksInRightSidebarImmediate,
|
||||
setOpenMobileTasksInPopupImmediate,
|
||||
setTaskPopupsBoardListOnlyImmediate,
|
||||
setShowCostBadgeOnCardsImmediate,
|
||||
setTaskDetailChatFirstImmediate,
|
||||
setMobileNavPrimaryItemsImmediate,
|
||||
toggleAutoReloadOnVersionChange,
|
||||
refresh,
|
||||
|
||||
@@ -151,7 +151,7 @@ function TaskDetailTitleModalHarness() {
|
||||
openNewTaskWithDescription: noop,
|
||||
openWorkflowEditor: noop,
|
||||
};
|
||||
return <div data-testid="title-host-modal"><NavigationHistoryProvider value={{ pushNav: noop, replaceCurrent: noop, removeNav: noop }}><AppModals projectId="fixture" tasks={[fixtureTask]} projects={[]} currentProject={null} addToast={noop} toasts={[]} removeToast={noop} modalManager={modalManager as never} projectActions={{} as never} taskHandlers={{} as never} taskOperations={{ moveTask: asyncTask, deleteTask: asyncTask, mergeTask: asyncMerge, archiveTask: asyncTask, retryTask: asyncTask, pauseTask: asyncTask, unpauseTask: asyncTask, resetTask: asyncTask, duplicateTask: asyncTask }} deepLink={{ handleDetailClose: noop }} settings={{ prAuthAvailable: false, autoMerge: true, taskDetailChatFirst: false, chatMessageLayout: "bubbles", themeMode: "system", colorTheme: "default", dashboardFontScalePct: 100, shadcnCustomColors: {}, resolvedThemeMode: "light", setThemeMode: noop, setColorTheme: noop, setDashboardFontScalePct: noop, setShadcnCustomColors: noop, setQuickChatButtonModeImmediate: noop, setChatMessageLayoutImmediate: noop, setMobileNavPrimaryItemsImmediate: noop }} /></NavigationHistoryProvider></div>;
|
||||
return <div data-testid="title-host-modal"><NavigationHistoryProvider value={{ pushNav: noop, replaceCurrent: noop, removeNav: noop }}><AppModals projectId="fixture" tasks={[fixtureTask]} projects={[]} currentProject={null} addToast={noop} toasts={[]} removeToast={noop} modalManager={modalManager as never} projectActions={{} as never} taskHandlers={{} as never} taskOperations={{ moveTask: asyncTask, deleteTask: asyncTask, mergeTask: asyncMerge, archiveTask: asyncTask, retryTask: asyncTask, pauseTask: asyncTask, unpauseTask: asyncTask, resetTask: asyncTask, duplicateTask: asyncTask }} deepLink={{ handleDetailClose: noop }} settings={{ prAuthAvailable: false, autoMerge: true, openTasksInRightSidebar: false, openMobileTasksInPopup: false, taskPopupsBoardListOnly: true, showCostBadgeOnCards: false, taskDetailChatFirst: false, chatMessageLayout: "bubbles", themeMode: "system", colorTheme: "default", dashboardFontScalePct: 100, shadcnCustomColors: {}, resolvedThemeMode: "light", setThemeMode: noop, setColorTheme: noop, setDashboardFontScalePct: noop, setShadcnCustomColors: noop, setQuickChatButtonModeImmediate: noop, setChatMessageLayoutImmediate: noop, setOpenTasksInRightSidebarImmediate: noop, setOpenMobileTasksInPopupImmediate: noop, setTaskPopupsBoardListOnlyImmediate: noop, setShowCostBadgeOnCardsImmediate: noop, setTaskDetailChatFirstImmediate: noop, setMobileNavPrimaryItemsImmediate: noop }} /></NavigationHistoryProvider></div>;
|
||||
}
|
||||
|
||||
function TaskDetailTitleMainPanelHarness() {
|
||||
|
||||
Reference in New Issue
Block a user