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:
gsxdsm
2026-06-28 13:17:33 -07:00
parent 8facc20eee
commit 6cf6ad3bd2
12 changed files with 135 additions and 2 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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