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:
Fusion Agent
2026-08-19 18:31:42 +00:00
parent 67becd9fc2
commit da3c280672
13 changed files with 359 additions and 16 deletions

View 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.

View File

@@ -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")}

View File

@@ -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}

View File

@@ -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}
/>

View File

@@ -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();

View File

@@ -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")}

View File

@@ -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();

View File

@@ -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>>;

View File

@@ -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:

View File

@@ -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 });

View File

@@ -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)

View File

@@ -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,

View File

@@ -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() {