FN-7202: add Quick Chat outside-click dismissal setting
Add a project setting that lets operators keep Quick Chat open until explicitly closed. - Add quickChatCloseOnOutsideClick to project settings defaults, schema, types, and app settings hook. - Wire the Quick Chat floating window to honor the setting while preserving default outside-click dismissal. - Add a Settings > General toggle, documentation, release changeset, and coverage for defaults, settings loading, modal form, and floating-window behavior. Files changed: .../fn-7202-quick-chat-outside-click-setting.md | 7 +++ docs/dashboard-guide.md | 2 +- docs/settings-reference.md | 1 + .../core/src/__tests__/settings-defaults.test.ts | 13 +++++ packages/core/src/settings-schema.ts | 5 ++ packages/core/src/types.ts | 6 +++ packages/dashboard/app/App.tsx | 6 ++- .../components/__tests__/FloatingWindow.test.tsx | 13 +++++ .../__tests__/SettingsModal.general.test.tsx | 8 +++ .../settings/sections/GeneralSection.tsx | 9 ++++ .../app/hooks/__tests__/useAppSettings.test.ts | 62 ++++++++++++++++++++++ packages/dashboard/app/hooks/useAppSettings.ts | 5 ++ 12 files changed, 135 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-7202 Fusion-Task-Lineage: 4d52ea30-6475-404e-b1b9-28e8e581005c Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7202-quick-chat-outside-click-setting.md
Normal file
7
.changeset/fn-7202-quick-chat-outside-click-setting.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 && (
|
||||
<QuickChatFAB
|
||||
@@ -1544,7 +1548,7 @@ function AppInner() {
|
||||
windowKey="chat-modal"
|
||||
title="Chat"
|
||||
onClose={() => setQuickChatOpen(false)}
|
||||
closeOnOutsidePointerDown
|
||||
closeOnOutsidePointerDown={quickChatCloseOnOutsideClick}
|
||||
hideHeader
|
||||
dragHandleSelector=".chat-view--floating .view-header"
|
||||
className="floating-window--chat"
|
||||
|
||||
@@ -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(
|
||||
<FloatingWindow windowKey="outside-disabled" title="Outside disabled" onClose={onClose} closeOnOutsidePointerDown={false}>
|
||||
<div>chat body</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -131,6 +131,15 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast
|
||||
</select>
|
||||
<small>{t("settings.general.quickChatLauncherHint", "Choose whether Quick Chat opens from the draggable floating button, a footer button beside Terminal, or stays hidden.")}</small>
|
||||
</div>
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<div className="form-group">
|
||||
<label htmlFor="quickChatCloseOnOutsideClick" className="checkbox-label">
|
||||
<input id="quickChatCloseOnOutsideClick" type="checkbox" checked={form.quickChatCloseOnOutsideClick !== false} onChange={(e) => setForm((f) => ({ ...f, quickChatCloseOnOutsideClick: e.target.checked }))}/>{t("settings.general.quickChatCloseOnOutsideClick", "Close Quick Chat on outside click")}</label>
|
||||
<small>{t("settings.general.quickChatCloseOnOutsideClickHint", "When enabled, clicking outside the Quick Chat window closes it. Disable to keep it open until you close it explicitly.")}</small>
|
||||
</div>
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.general.chatHistory", "Chat history")}</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="chatAutoCleanupDays">{t("settings.general.autoCleanupOldChats", "Auto-cleanup old chats")}</label>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<QuickChatButtonMode>("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,
|
||||
|
||||
Reference in New Issue
Block a user