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:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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()} />);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user