diff --git a/.changeset/fn-7202-quick-chat-outside-click-setting.md b/.changeset/fn-7202-quick-chat-outside-click-setting.md new file mode 100644 index 0000000000..ad4463e76f --- /dev/null +++ b/.changeset/fn-7202-quick-chat-outside-click-setting.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a setting to control whether clicking outside the Quick Chat window closes it. +category: feature +dev: New project setting `quickChatCloseOnOutsideClick` (default true, preserving FN-7152 behavior). Wired through ProjectSettings/DEFAULT_PROJECT_SETTINGS, useAppSettings, the Settings → General toggle, and the Quick Chat FloatingWindow `closeOnOutsidePointerDown` prop. Project-scoped only. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 4dd060f21a..1639bb0b7a 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -418,7 +418,7 @@ Quick Chat is an optional fast, project-scoped assistant surface for conversatio - Submitting the inline chooser uses explicit fresh-session creation and immediately persists/selects the new thread, then refreshes the session dropdown list - On first open for a project, Quick Chat restores the last opened non-archived session from per-project local storage; if that saved session is missing, it falls back to the most recently touched non-archived session by latest activity (`max(lastMessageAt, updatedAt)`), and only falls back to the first agent / configured default model when no prior session exists. - Closing and reopening Quick Chat keeps the active conversation warm in memory, so messages stay visible without a conversation reload or "Loading conversation…" flash. -- Clicking outside the desktop Quick Chat floating window closes it; task pop-out floating windows remain persistent on page clicks. +- Clicking outside the desktop Quick Chat floating window closes it by default; disable **Settings → General → Close Quick Chat on outside click** to keep it open until you explicitly close/minimize/maximize it. Task pop-out floating windows remain persistent on page clicks. - Queued follow-up messages entered while a Quick Chat response is still streaming now persist per session, so closing/reopening the panel restores the queued stack and flushes the messages one at a time in FIFO order as active responses complete. - Resume lookups still use targeted session queries instead of loading the full active-session list first - Tool-call summaries in the floating quick-chat panel are intentionally condensed into a single-line header row (especially on small screens) so tool name + status stay scannable without multi-line wrapping diff --git a/docs/settings-reference.md b/docs/settings-reference.md index c049d5a464..4f5a99867a 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -621,6 +621,7 @@ Default notes: | `reflectionAfterTask` | `boolean` | `true` | Trigger reflection after task completion. | | `reviewHandoffPolicy` | `"disabled" \| "comment-triggered" \| "always"` | `"disabled"` | Policy for agent-to-user review handoff detection. | | `showQuickChatFAB` | `boolean` | `false` | Show floating quick-chat button (chat remains available via More menu). | +| `quickChatCloseOnOutsideClick` | `boolean` | `true` | Close the desktop Quick Chat floating window when clicking outside it; disable to keep it open until explicitly closed. | | `chatAutoCleanupDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `0` | Auto-cleanup retention window for idle chat sessions and chat rooms. `0` is off (default). When enabled, periodic self-healing maintenance deletes rows with `updatedAt` older than the configured day window. | | `mailAutoCleanupDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `0` | Auto-prune retention window for inbox/outbox mail messages. `0` is off (default). When enabled, periodic self-healing maintenance deletes `messages` rows where `updatedAt < cutoff` for the configured day window. Suggested setting: `7`. | | `operationalLogRetentionDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `30` | Retention window for SQLite operational-log tables (`activityLog`, `runAuditEvents`, `agentHeartbeats`), terminal `agentRuns` rows (by `endedAt`), and `agentConfigRevisions` (by `createdAt`). `0` is off. Lower values mean Reliability metrics/charts and the Activity feed will not show history older than the configured window; per-task task detail history is unaffected. Periodic maintenance prunes timestamped operational-log rows older than this many days while always preserving in-flight `agentRuns` (`endedAt IS NULL`) and the most-recent `agentConfigRevisions` row per agent. | diff --git a/packages/core/src/__tests__/settings-defaults.test.ts b/packages/core/src/__tests__/settings-defaults.test.ts index 2a309736a8..171192f6ce 100644 --- a/packages/core/src/__tests__/settings-defaults.test.ts +++ b/packages/core/src/__tests__/settings-defaults.test.ts @@ -150,6 +150,19 @@ describe("settings defaults invariants", () => { }); }); + describe("quickChatCloseOnOutsideClick default", () => { + it("keeps Quick Chat outside-click dismissal explicitly true in project defaults", () => { + expect(DEFAULT_PROJECT_SETTINGS.quickChatCloseOnOutsideClick).toBe(true); + expect("quickChatCloseOnOutsideClick" in DEFAULT_PROJECT_SETTINGS).toBe(true); + expect(PROJECT_SETTINGS_KEYS).toContain("quickChatCloseOnOutsideClick"); + }); + + it("keeps quickChatCloseOnOutsideClick project-scoped only", () => { + expect("quickChatCloseOnOutsideClick" in DEFAULT_GLOBAL_SETTINGS).toBe(false); + expect(GLOBAL_SETTINGS_KEYS).not.toContain("quickChatCloseOnOutsideClick"); + }); + }); + describe("mergeIntegrationWorktree default", () => { it("defaults project settings to reuse-task-worktree", () => { expect(DEFAULT_PROJECT_SETTINGS.mergeIntegrationWorktree).toBe("reuse-task-worktree"); diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 28bb4f6fe7..5bfc611a57 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -520,6 +520,11 @@ export const DEFAULT_PROJECT_SETTINGS = { reflectionAfterTask: true, // reviewHandoffPolicy MOVED to workflow settings (U4) — see MOVED_SETTINGS_KEYS. quickChatButtonMode: "off", + /* + FNXC:ChatModal 2026-06-28-00:00: + Quick Chat outside-click dismissal remains default-on for upgrades, but it is now a project setting so operators can disable accidental board-click closes. + */ + quickChatCloseOnOutsideClick: true, showQuickChatFAB: false, chatAutoCleanupDays: 0, mailAutoCleanupDays: 0, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 2cae44f741..e8c35f90e8 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -4368,6 +4368,12 @@ export interface ProjectSettings { reviewHandoffPolicy?: "disabled" | "comment-triggered" | "always"; /** Quick Chat launcher placement. "floating" shows the draggable FAB, "footer" shows a footer button, "off" hides both. */ quickChatButtonMode?: "floating" | "footer" | "off"; + /** + * FNXC:ChatModal 2026-06-28-00:00: + * Outside-click dismissal of Quick Chat is now user-configurable; default true preserves the prior always-on behavior from FN-7152. + * When true (default), the Quick Chat floating window closes when the user clicks outside it. Set false to keep it open until explicitly closed. + */ + quickChatCloseOnOutsideClick?: boolean; /** Legacy Quick Chat FAB toggle. Prefer quickChatButtonMode for new callers. */ showQuickChatFAB?: boolean; /** Number of days of chat inactivity before old chat sessions/rooms are auto-cleaned. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 8471104c00..a27d7773e3 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -540,6 +540,7 @@ function AppInner() { capacityRiskTodoThreshold, openTasksInRightSidebar, quickChatButtonMode, + quickChatCloseOnOutsideClick, maxTotalRetriesBeforeFail, prAuthAvailable, settingsLoaded, @@ -1531,6 +1532,9 @@ function AppInner() { FNXC:ChatModal 2026-06-27-00:00: Quick Chat is a transient utility window, so it opts into FloatingWindow's outside-click dismissal in addition to minimize, close, and maximize controls. Task pop-outs intentionally do not opt in because they are persistent workspace windows that should survive page clicks. + + FNXC:ChatModal 2026-06-28-00:00: + Outside-click dismissal is now governed by the project-scoped quickChatCloseOnOutsideClick setting, default-on to preserve FN-7152 behavior. Other FloatingWindow callers still do not pass closeOnOutsidePointerDown, so task pop-outs and utility windows remain persistent. */} {viewMode === "project" && currentProject && ( setQuickChatOpen(false)} - closeOnOutsidePointerDown + closeOnOutsidePointerDown={quickChatCloseOnOutsideClick} hideHeader dragHandleSelector=".chat-view--floating .view-header" className="floating-window--chat" diff --git a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx index d8eb73732c..bc19438a03 100644 --- a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx +++ b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx @@ -149,6 +149,19 @@ describe("FloatingWindow", () => { expect(onClose).not.toHaveBeenCalled(); }); + it("does not close on outside pointerdown when the opt-in prop is explicitly false", () => { + const onClose = vi.fn(); + render( + +
chat body
+
+ ); + + fireEvent.pointerDown(document.body); + + expect(onClose).not.toHaveBeenCalled(); + }); + it("does not close when the outside target is another floating or dialog surface", () => { for (const surfaceClassOrRole of ["modal-overlay", "floating-window", "dialog-role"] as const) { const onClose = vi.fn(); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx index a6c6654bba..18b489e35e 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx @@ -708,6 +708,14 @@ describe("SettingsModal", () => { scope: "project", expectedKey: "chatAutoCleanupDays", }, + { + section: "Project General", + label: "Close Quick Chat on outside click", + kind: "checkbox", + value: false, + scope: "project", + expectedKey: "quickChatCloseOnOutsideClick", + }, { section: "Project General", label: "Operational log retention", diff --git a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx index f6c83efce5..0085550892 100644 --- a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx @@ -131,6 +131,15 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast {t("settings.general.quickChatLauncherHint", "Choose whether Quick Chat opens from the draggable floating button, a footer button beside Terminal, or stays hidden.")} + {/* + FNXC:ChatModal 2026-06-28-00:00: + Operators need a Settings > General toggle for Quick Chat outside-click dismissal because accidental board clicks can otherwise close active chat context. Default checked preserves the shipped FN-7152 interaction. + */} +
+ + {t("settings.general.quickChatCloseOnOutsideClickHint", "When enabled, clicking outside the Quick Chat window closes it. Disable to keep it open until you close it explicitly.")} +

{t("settings.general.chatHistory", "Chat history")}

diff --git a/packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts b/packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts index 23fcf751db..4f8082f289 100644 --- a/packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts @@ -52,6 +52,7 @@ describe("useAppSettings", () => { expect(result.current.taskStuckTimeoutMs).toBe(600000); expect(result.current.staleHighFanoutBlockerAgeThresholdMs).toBe(7200000); expect(result.current.showQuickChatFAB).toBe(false); + expect(result.current.quickChatCloseOnOutsideClick).toBe(true); expect(result.current.capacityRiskBannerEnabled).toBe(false); expect(result.current.capacityRiskTodoThreshold).toBe(20); }); @@ -219,6 +220,67 @@ describe("useAppSettings", () => { }); }); + it("loads Quick Chat outside-click dismissal as default-on unless explicitly disabled", async () => { + const { result, rerender } = renderHook(({ projectId }) => useAppSettings(projectId), { + initialProps: { projectId: "proj_123" }, + }); + + await waitFor(() => { + expect(result.current.quickChatCloseOnOutsideClick).toBe(true); + }); + + mockFetchSettings.mockResolvedValueOnce({ + autoMerge: false, + globalPause: false, + enginePaused: false, + prAuthAvailable: true, + taskStuckTimeoutMs: 600000, + showQuickChatFAB: false, + quickChatCloseOnOutsideClick: false, + } as never); + + rerender({ projectId: "proj_456" }); + + await waitFor(() => { + expect(result.current.quickChatCloseOnOutsideClick).toBe(false); + }); + }); + + it("refresh() live-applies saved Quick Chat outside-click setting changes", async () => { + mockFetchSettings.mockResolvedValueOnce({ + autoMerge: false, + globalPause: false, + enginePaused: false, + prAuthAvailable: true, + taskStuckTimeoutMs: 600000, + showQuickChatFAB: false, + quickChatCloseOnOutsideClick: false, + } as never); + const { result } = renderHook(() => useAppSettings("proj_123")); + + await waitFor(() => { + expect(result.current.quickChatCloseOnOutsideClick).toBe(false); + }); + + mockFetchSettings.mockResolvedValueOnce({ + autoMerge: false, + globalPause: false, + enginePaused: false, + prAuthAvailable: true, + taskStuckTimeoutMs: 600000, + showQuickChatFAB: false, + quickChatCloseOnOutsideClick: true, + } as never); + + await act(async () => { + await result.current.refresh(); + }); + + await waitFor(() => { + expect(result.current.quickChatCloseOnOutsideClick).toBe(true); + }); + }); + it("propagates capacity risk settings from fetchSettings", async () => { mockFetchSettings.mockResolvedValueOnce({ autoMerge: false, diff --git a/packages/dashboard/app/hooks/useAppSettings.ts b/packages/dashboard/app/hooks/useAppSettings.ts index 4b0c71903c..1906417dc5 100644 --- a/packages/dashboard/app/hooks/useAppSettings.ts +++ b/packages/dashboard/app/hooks/useAppSettings.ts @@ -22,6 +22,7 @@ export interface UseAppSettingsResult { capacityRiskTodoThreshold: number; openTasksInRightSidebar: boolean; quickChatButtonMode: QuickChatButtonMode; + quickChatCloseOnOutsideClick: boolean; showQuickChatFAB: boolean; maxTotalRetriesBeforeFail: number; prAuthAvailable: boolean; @@ -61,6 +62,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { const [capacityRiskTodoThreshold, setCapacityRiskTodoThreshold] = useState(20); const [openTasksInRightSidebar, setOpenTasksInRightSidebar] = useState(false); const [quickChatButtonMode, setQuickChatButtonMode] = useState("off"); + const [quickChatCloseOnOutsideClick, setQuickChatCloseOnOutsideClick] = useState(true); const [showQuickChatFAB, setShowQuickChatFAB] = useState(false); const [maxTotalRetriesBeforeFail, setMaxTotalRetriesBeforeFail] = useState(25); const [prAuthAvailable, setPrAuthAvailable] = useState(false); @@ -111,6 +113,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { ? "floating" : "off"; setQuickChatButtonMode(nextQuickChatButtonMode); + setQuickChatCloseOnOutsideClick(settings.quickChatCloseOnOutsideClick !== false); setShowQuickChatFAB(nextQuickChatButtonMode === "floating"); setMaxTotalRetriesBeforeFail(settings.maxTotalRetriesBeforeFail ?? 25); setCapacityRiskBannerEnabled(settings.capacityRiskBannerEnabled === true); @@ -143,6 +146,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { setMemoryEnabled(true); setDevServerEnabled(false); setOpenTasksInRightSidebar(false); + setQuickChatCloseOnOutsideClick(true); setTodosEnabled(true); setGoalsEnabled(true); void refresh(); @@ -244,6 +248,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { capacityRiskTodoThreshold, openTasksInRightSidebar, quickChatButtonMode, + quickChatCloseOnOutsideClick, showQuickChatFAB, maxTotalRetriesBeforeFail, prAuthAvailable,