fix(FN-1488): add showQuickChatFAB setting to control Quick Chat FAB visibility

- Add showQuickChatFAB boolean setting to ProjectSettings type
- Add useAppSettings hook function for accessing the setting
- Wire QuickChatFAB visibility in App.tsx based on setting
- Hide QuickChatFAB in MobileNavBar when setting is false
- Add Settings UI toggle for the new setting
- Add CSS for hiding QuickChatFAB on mobile when disabled
- Add comprehensive tests for all affected components
- Fix QuickChatFAB onOpenChange test to use controlled mode
- Update settings reference documentation
This commit is contained in:
gsxdsm
2026-04-11 00:18:22 -07:00
parent 5efe862c68
commit afa2e153ee
15 changed files with 271 additions and 13 deletions

View File

@@ -0,0 +1,5 @@
---
"@gsxdsm/fusion": patch
---
Fix mobile quick-chat UX: add showQuickChatFAB project setting to hide the floating button, add Chat access to mobile More menu, and fix FAB/nav bar overlap on small screens.

View File

@@ -96,6 +96,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
| `buildRetryCount` | `number` | `0` | Build retry attempts during merge. |
| `buildTimeoutMs` | `number` | `300000` | Build timeout in ms (5 minutes). |
| `requirePlanApproval` | `boolean` | `false` | Require manual approval before triage → todo. |
| `showQuickChatFAB` | `boolean` | `true` | Show floating quick-chat button. Chat accessible from More menu when hidden. |
| `taskStuckTimeoutMs` | `number` | `undefined` | Inactivity timeout for stuck-task recovery. |
| `autoUnpauseEnabled` | `boolean` | `true` | Auto-unpause after rate-limit-triggered pauses. |
| `autoUnpauseBaseDelayMs` | `number` | `300000` | Base unpause retry delay in ms (5 min). |

View File

@@ -99,6 +99,7 @@ const PROJECT_KEYS: (keyof ProjectSettings)[] = [
"reflectionIntervalMs",
"reflectionAfterTask",
"reviewHandoffPolicy",
"showQuickChatFAB",
];
function assertExactKeyCoverage(scopeName: string, actual: readonly string[], expected: readonly string[]): void {

View File

@@ -1144,6 +1144,10 @@ export interface ProjectSettings {
* - "always": Always handoff after completion (not implemented, reserved for future)
*/
reviewHandoffPolicy?: "disabled" | "comment-triggered" | "always";
/** When true, show the quick-chat floating action button (FAB) in the dashboard.
* When false, the FAB is hidden but chat remains accessible via the More menu.
* Default: true. */
showQuickChatFAB?: boolean;
}
/**
@@ -1255,6 +1259,7 @@ export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
reflectionIntervalMs: 3_600_000,
reflectionAfterTask: true,
reviewHandoffPolicy: "disabled",
showQuickChatFAB: true,
};
/**
@@ -1361,6 +1366,7 @@ export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
"reflectionIntervalMs",
"reflectionAfterTask",
"reviewHandoffPolicy",
"showQuickChatFAB",
] as const;
// ── Compile-time parity: ensures every interface key is listed exactly once ──

View File

@@ -121,6 +121,7 @@ function AppInner() {
const [nodesOpen, setNodesOpen] = useState(false);
const [missionResumeSessionId, setMissionResumeSessionId] = useState<string | undefined>(undefined);
const [missionTargetId, setMissionTargetId] = useState<string | undefined>(undefined);
const [quickChatOpen, setQuickChatOpen] = useState(false);
// Settings state
const {
@@ -130,6 +131,7 @@ function AppInner() {
globalPaused,
enginePaused,
taskStuckTimeoutMs,
showQuickChatFAB,
githubTokenConfigured,
toggleAutoMerge,
toggleGlobalPause,
@@ -505,10 +507,17 @@ function AppInner() {
onOpenUsage={modalManager.openUsage}
onViewAllProjects={handleViewAllProjects}
onRunScript={modalManager.runScript}
onOpenQuickChat={() => setQuickChatOpen(true)}
projectId={currentProject?.id}
/>
{viewMode === "project" && currentProject && taskView !== "chat" && (
<QuickChatFAB projectId={currentProject.id} addToast={addToast} />
<QuickChatFAB
projectId={currentProject.id}
addToast={addToast}
showFAB={showQuickChatFAB}
open={quickChatOpen}
onOpenChange={setQuickChatOpen}
/>
)}
<AppModals
projectId={currentProject?.id}

View File

@@ -59,6 +59,7 @@ const createDefaultMobileNavProps = () => ({
activePlanningSessionCount: 0,
onOpenUsage: vi.fn(),
onRunScript: vi.fn(),
onOpenQuickChat: vi.fn(),
projectId: "proj_1",
});
@@ -161,9 +162,20 @@ describe("Mobile Feature Access Regression Guard", () => {
expect(screen.getByTestId("mobile-more-item-schedules")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-github")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-usage")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-chat")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-settings")).toBeDefined();
});
it("chat is accessible via more sheet even when FAB is hidden", () => {
const props = createDefaultMobileNavProps();
render(<MobileNavBar {...props} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
fireEvent.click(screen.getByTestId("mobile-more-item-chat"));
expect(props.onOpenQuickChat).toHaveBeenCalledOnce();
});
it("mobile nav bar renders only on mobile viewport and hides for modal or desktop", () => {
const mobileRender = render(<MobileNavBar {...createDefaultMobileNavProps()} />);
expect(mobileRender.container.querySelector(".mobile-nav-bar")).not.toBeNull();

View File

@@ -50,6 +50,7 @@ export interface MobileNavBarProps {
activePlanningSessionCount?: number;
onOpenUsage?: () => void;
onRunScript?: (name: string, command: string) => void;
onOpenQuickChat?: () => void;
projectId?: string;
onViewAllProjects?: () => void;
}
@@ -93,6 +94,7 @@ export function MobileNavBar({
activePlanningSessionCount = 0,
onOpenUsage,
onRunScript,
onOpenQuickChat,
projectId,
onViewAllProjects,
}: MobileNavBarProps) {
@@ -440,6 +442,16 @@ export function MobileNavBar({
<span>Projects</span>
</button>
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-chat"
onClick={() => handleMoreAction(onOpenQuickChat)}
>
<MessageSquare />
<span>Chat</span>
</button>
<div className="mobile-more-separator" />
<button

View File

@@ -8,6 +8,12 @@ import { useAgents } from "../hooks/useAgents";
interface QuickChatFABProps {
projectId?: string;
addToast: (msg: string, type?: "success" | "error") => void;
/** When false, the FAB button is hidden but the panel can still be opened programmatically via the open prop */
showFAB?: boolean;
/** When true, the chat panel is open */
open?: boolean;
/** Callback when the panel should be opened/closed */
onOpenChange?: (open: boolean) => void;
}
function getAgentLabel(agent: Agent): string {
@@ -15,9 +21,21 @@ function getAgentLabel(agent: Agent): string {
return `${base} (${agent.role})`;
}
export function QuickChatFAB({ projectId, addToast }: QuickChatFABProps) {
export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpenChange }: QuickChatFABProps) {
const { agents } = useAgents(projectId);
const [isOpen, setIsOpen] = useState(false);
// Internal state for uncontrolled mode, controlled state when open prop is provided
const [internalOpen, setInternalOpen] = useState(false);
const isControlled = open !== undefined;
const isOpen = isControlled ? open : internalOpen;
const setIsOpen = isControlled
? (value: boolean | ((prev: boolean) => boolean)) => {
if (typeof value === "function") {
onOpenChange?.(value(isOpen));
} else {
onOpenChange?.(value);
}
}
: setInternalOpen;
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
const [messages, setMessages] = useState<Message[]>([]);
const [isConversationLoading, setIsConversationLoading] = useState(false);
@@ -137,16 +155,18 @@ export function QuickChatFAB({ projectId, addToast }: QuickChatFABProps) {
return (
<>
<button
ref={fabRef}
type="button"
className="quick-chat-fab"
aria-label="Open quick chat"
data-testid="quick-chat-fab"
onClick={() => setIsOpen((open) => !open)}
>
<MessageSquare size={24} />
</button>
{showFAB && (
<button
ref={fabRef}
type="button"
className="quick-chat-fab"
aria-label="Open quick chat"
data-testid="quick-chat-fab"
onClick={() => setIsOpen((open) => !open)}
>
<MessageSquare size={24} />
</button>
)}
{isOpen && (
<div className="quick-chat-panel" ref={panelRef} data-testid="quick-chat-panel">

View File

@@ -654,6 +654,20 @@ export function SettingsModal({
</label>
<small>When enabled, AI-generated task specifications require manual approval before moving to Todo</small>
</div>
<div className="form-group">
<label htmlFor="showQuickChatFAB" className="checkbox-label">
<input
id="showQuickChatFAB"
type="checkbox"
checked={form.showQuickChatFAB !== false}
onChange={(e) =>
setForm((f) => ({ ...f, showQuickChatFAB: e.target.checked }))
}
/>
Show quick chat button
</label>
<small>Show the floating chat button in the dashboard. Chat is still accessible from the More menu.</small>
</div>
</>
);
case "models": {

View File

@@ -47,6 +47,7 @@ const createDefaultProps = () => ({
onOpenUsage: vi.fn(),
onViewAllProjects: vi.fn(),
onRunScript: vi.fn(),
onOpenQuickChat: vi.fn(),
projectId: "proj_1",
});
@@ -149,6 +150,7 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-more-item-github")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-usage")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-projects")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-chat")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-settings")).toBeDefined();
});
@@ -185,6 +187,17 @@ describe("MobileNavBar", () => {
expect(props.onViewAllProjects).toHaveBeenCalledOnce();
});
it("calls onOpenQuickChat from the Chat more-sheet item", () => {
const props = createDefaultProps();
const { container } = render(<MobileNavBar {...props} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
fireEvent.click(screen.getByTestId("mobile-more-item-chat"));
expect(container.querySelector(".mobile-more-sheet")).toBeNull();
expect(props.onOpenQuickChat).toHaveBeenCalledOnce();
});
it("closes sheet on backdrop click", () => {
const { container } = render(<MobileNavBar {...createDefaultProps()} />);

View File

@@ -227,4 +227,54 @@ describe("QuickChatFAB", () => {
expect(screen.queryByTestId("quick-chat-panel")).toBeNull();
});
});
it("hides FAB button when showFAB is false", () => {
render(<QuickChatFAB addToast={addToast} showFAB={false} />);
expect(screen.queryByTestId("quick-chat-fab")).toBeNull();
});
it("still opens panel programmatically when showFAB is false with controlled open prop", async () => {
render(<QuickChatFAB addToast={addToast} showFAB={false} open={true} />);
expect(screen.queryByTestId("quick-chat-fab")).toBeNull();
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
});
it("controlled open prop opens panel without clicking FAB", () => {
render(<QuickChatFAB addToast={addToast} open={true} />);
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
});
it("controlled open prop defaults to closed when not set", () => {
render(<QuickChatFAB addToast={addToast} />);
expect(screen.queryByTestId("quick-chat-panel")).toBeNull();
});
it("onOpenChange callback is called when panel is opened via FAB (controlled mode)", async () => {
const onOpenChange = vi.fn();
render(<QuickChatFAB addToast={addToast} open={false} onOpenChange={onOpenChange} />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(onOpenChange).toHaveBeenCalledWith(true);
});
});
it("onOpenChange callback is called when panel is closed via FAB", async () => {
const onOpenChange = vi.fn();
render(<QuickChatFAB addToast={addToast} open={true} onOpenChange={onOpenChange} />);
// Panel should be open initially
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
});

View File

@@ -28,6 +28,7 @@ const defaultSettings: Settings = {
maxStuckKills: 6,
runStepsInNewSessions: false,
maxParallelSteps: 2,
showQuickChatFAB: true,
};
vi.mock("../../api", () => ({
@@ -400,6 +401,90 @@ describe("SettingsModal", () => {
expect(updateSettings).not.toHaveBeenCalled();
});
it("shows Show quick chat button checkbox in General section", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getAllByText("General")[0]);
const checkbox = screen.getByLabelText("Show quick chat button");
expect(checkbox).toBeTruthy();
expect(checkbox.getAttribute("type")).toBe("checkbox");
});
it("showQuickChatFAB defaults to checked (true) when not set", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getAllByText("General")[0]);
const checkbox = screen.getByLabelText("Show quick chat button") as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
it("showQuickChatFAB defaults to checked when setting is true", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
showQuickChatFAB: true,
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getAllByText("General")[0]);
const checkbox = screen.getByLabelText("Show quick chat button") as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
it("showQuickChatFAB is unchecked when setting is false", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
showQuickChatFAB: false,
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getAllByText("General")[0]);
const checkbox = screen.getByLabelText("Show quick chat button") as HTMLInputElement;
expect(checkbox.checked).toBe(false);
});
it("toggling showQuickChatFAB checkbox sends false in save payload when unchecked", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getAllByText("General")[0]);
const checkbox = screen.getByLabelText("Show quick chat button");
// Default is checked (true), click to uncheck
fireEvent.click(checkbox);
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.showQuickChatFAB).toBe(false);
});
it("toggling showQuickChatFAB checkbox sends true in save payload when checked", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
showQuickChatFAB: false,
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getAllByText("General")[0]);
const checkbox = screen.getByLabelText("Show quick chat button");
// Default is unchecked (false), click to check
fireEvent.click(checkbox);
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.showQuickChatFAB).toBe(true);
});
it("shows Auto-completion mode select in Merge section", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());

View File

@@ -28,6 +28,7 @@ describe("useAppSettings", () => {
enginePaused: false,
githubTokenConfigured: true,
taskStuckTimeoutMs: 600000,
showQuickChatFAB: false,
} as never);
mockUpdateSettings.mockResolvedValue({} as never);
@@ -44,6 +45,7 @@ describe("useAppSettings", () => {
expect(result.current.enginePaused).toBe(false);
expect(result.current.githubTokenConfigured).toBe(true);
expect(result.current.taskStuckTimeoutMs).toBe(600000);
expect(result.current.showQuickChatFAB).toBe(false);
});
expect(mockFetchConfig).toHaveBeenCalledWith("proj_123");

View File

@@ -11,10 +11,12 @@ export interface UseAppSettingsResult {
globalPaused: boolean;
enginePaused: boolean;
taskStuckTimeoutMs: number | undefined;
showQuickChatFAB: boolean;
githubTokenConfigured: boolean;
toggleAutoMerge: () => Promise<void>;
toggleGlobalPause: () => Promise<void>;
toggleEnginePause: () => Promise<void>;
toggleShowQuickChatFAB: () => Promise<void>;
}
/**
@@ -27,6 +29,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
const [globalPaused, setGlobalPaused] = useState(false);
const [enginePaused, setEnginePaused] = useState(false);
const [taskStuckTimeoutMs, setTaskStuckTimeoutMs] = useState<number | undefined>(undefined);
const [showQuickChatFAB, setShowQuickChatFAB] = useState(true);
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
useEffect(() => {
@@ -46,6 +49,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
setEnginePaused(Boolean(settings.enginePaused));
setGithubTokenConfigured(Boolean(settings.githubTokenConfigured));
setTaskStuckTimeoutMs(settings.taskStuckTimeoutMs);
setShowQuickChatFAB(settings.showQuickChatFAB !== false);
})
.catch(() => {
// Keep defaults on fetch failure.
@@ -85,6 +89,17 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
}
}, [enginePaused, projectId]);
const toggleShowQuickChatFAB = useCallback(async () => {
const next = !showQuickChatFAB;
setShowQuickChatFAB(next);
try {
await updateSettings({ showQuickChatFAB: next }, projectId);
} catch {
setShowQuickChatFAB(!next);
}
}, [showQuickChatFAB, projectId]);
return {
maxConcurrent,
rootDir,
@@ -92,9 +107,11 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
globalPaused,
enginePaused,
taskStuckTimeoutMs,
showQuickChatFAB,
githubTokenConfigured,
toggleAutoMerge,
toggleGlobalPause,
toggleEnginePause,
toggleShowQuickChatFAB,
};
}

View File

@@ -25225,6 +25225,17 @@ html .column.drag-over * {
cursor: not-allowed;
}
/* Mobile: position FAB and panel above the bottom nav bar */
@media (max-width: 768px) {
.quick-chat-fab {
bottom: calc(24px + var(--executor-footer-height, 0px) + var(--mobile-nav-height, 44px) + env(safe-area-inset-bottom, 0px));
}
.quick-chat-panel {
bottom: calc(84px + var(--executor-footer-height, 0px) + var(--mobile-nav-height, 44px) + env(safe-area-inset-bottom, 0px));
}
}
/* ── Chat View ─────────────────────────────────────────────────────────────── */
.chat-view {