diff --git a/.changeset/fn-049-live-dashboard-settings.md b/.changeset/fn-049-live-dashboard-settings.md new file mode 100644 index 0000000000..cac6f98030 --- /dev/null +++ b/.changeset/fn-049-live-dashboard-settings.md @@ -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. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 5472833097..822db186db 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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")} diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index 5a78defb8e..bf4e075796 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -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) => 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} diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 3cb7a75303..ba76c2708b 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -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} /> diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx index c91c9db385..2a9ab7f495 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx @@ -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(); diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index 6d849b2655..014d47024b 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -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")} diff --git a/packages/dashboard/app/components/dashboard/__tests__/MainContent.graph-popout.test.tsx b/packages/dashboard/app/components/dashboard/__tests__/MainContent.graph-popout.test.tsx index 7cf0d88d20..9a0c616cd0 100644 --- a/packages/dashboard/app/components/dashboard/__tests__/MainContent.graph-popout.test.tsx +++ b/packages/dashboard/app/components/dashboard/__tests__/MainContent.graph-popout.test.tsx @@ -65,6 +65,13 @@ const LazyStub = lazy(async () => ({ default: () => null })); const LazySettingsCloseStub = lazy(async () => ({ default: ({ onClose }: { onClose: () => void }) => , })); +let embeddedSettingsProps: Record | undefined; +const LazySettingsBridgeStub = lazy(async () => ({ + default: (props: Record) => { + embeddedSettingsProps = props; + return
Embedded settings bridge
; + }, +})); function mainContentProps(overrides: Partial = {}): MainContentProps { return { @@ -94,6 +101,12 @@ function mainContentProps(overrides: Partial = {}): 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 = {}): 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(); + + 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(); diff --git a/packages/dashboard/app/components/dashboard/types.ts b/packages/dashboard/app/components/dashboard/types.ts index 97a2cc9c61..b288cfbe09 100644 --- a/packages/dashboard/app/components/dashboard/types.ts +++ b/packages/dashboard/app/components/dashboard/types.ts @@ -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; setQuickChatOpen: Dispatch>; diff --git a/packages/dashboard/app/components/settings/sections/AppearanceSection.tsx b/packages/dashboard/app/components/settings/sections/AppearanceSection.tsx index 68616562e1..fcd6220a88 100644 --- a/packages/dashboard/app/components/settings/sections/AppearanceSection.tsx +++ b/packages/dashboard/app/components/settings/sections/AppearanceSection.tsx @@ -18,6 +18,16 @@ export interface AppearanceSectionProps extends SectionBaseProps { onShadcnCustomColorsChange?: (colors: Record) => 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 (<>

{t("settings.appearance.title", "Appearance")}

@@ -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. */} 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. */} 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. */} 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. */} 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: diff --git a/packages/dashboard/app/components/settings/sections/__tests__/AppearanceSection.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/AppearanceSection.test.tsx index b7bd1d1fba..0a650b62c3 100644 --- a/packages/dashboard/app/components/settings/sections/__tests__/AppearanceSection.test.tsx +++ b/packages/dashboard/app/components/settings/sections/__tests__/AppearanceSection.test.tsx @@ -13,6 +13,11 @@ vi.mock("../../LanguageSelector", () => ({ })); function renderAppearanceSection(formOverrides: Partial = {}, 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 = {}, 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 }); diff --git a/packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts b/packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts index 99d050cbae..178bce4be5 100644 --- a/packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts @@ -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) diff --git a/packages/dashboard/app/hooks/useAppSettings.ts b/packages/dashboard/app/hooks/useAppSettings.ts index dfe3b6a1f4..b340d5ea08 100644 --- a/packages/dashboard/app/hooks/useAppSettings.ts +++ b/packages/dashboard/app/hooks/useAppSettings.ts @@ -68,6 +68,11 @@ export interface UseAppSettingsResult { toggleShowQuickChatFAB: () => Promise; 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; /** 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, diff --git a/packages/dashboard/app/task-modal-touch-resize-e2e-fixture.tsx b/packages/dashboard/app/task-modal-touch-resize-e2e-fixture.tsx index 8a03a4c4d6..1111d6f12b 100644 --- a/packages/dashboard/app/task-modal-touch-resize-e2e-fixture.tsx +++ b/packages/dashboard/app/task-modal-touch-resize-e2e-fixture.tsx @@ -151,7 +151,7 @@ function TaskDetailTitleModalHarness() { openNewTaskWithDescription: noop, openWorkflowEditor: noop, }; - return
; + return
; } function TaskDetailTitleMainPanelHarness() {