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

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