feat(FN-1104): add quick chat FAB to dashboard
- Add QuickChatFAB component with agent selection, conversation loading, and inline send actions - Support panel open/close interactions including close button, Escape key, and outside-click dismissal - Integrate the quick chat FAB into App so it appears in project view with project-scoped API calls - Add comprehensive QuickChatFAB tests and style rules for FAB, chat panel, message bubbles, and responsive behavior
This commit is contained in:
@@ -30,6 +30,7 @@ import { NodesView } from "./components/NodesView";
|
||||
import { MailboxModal } from "./components/MailboxModal";
|
||||
import { ScriptsModal } from "./components/ScriptsModal";
|
||||
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
||||
import { QuickChatFAB } from "./components/QuickChatFAB";
|
||||
import { useBackgroundSessions } from "./hooks/useBackgroundSessions";
|
||||
import { useTasks } from "./hooks/useTasks";
|
||||
import { useProjects } from "./hooks/useProjects";
|
||||
@@ -750,6 +751,9 @@ function AppInner() {
|
||||
onDismissBackgroundSession={bgDismiss}
|
||||
/>
|
||||
)}
|
||||
{viewMode === "project" && currentProject && (
|
||||
<QuickChatFAB projectId={currentProject.id} addToast={addToast} />
|
||||
)}
|
||||
{detailTask && (
|
||||
<TaskDetailModal
|
||||
task={detailTask}
|
||||
|
||||
226
packages/dashboard/app/components/QuickChatFAB.tsx
Normal file
226
packages/dashboard/app/components/QuickChatFAB.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { MessageSquare, Send, X } from "lucide-react";
|
||||
import type { Message } from "@fusion/core";
|
||||
import type { Agent } from "../api";
|
||||
import { fetchConversation, sendMessage } from "../api";
|
||||
import { useAgents } from "../hooks/useAgents";
|
||||
|
||||
interface QuickChatFABProps {
|
||||
projectId?: string;
|
||||
addToast: (msg: string, type?: "success" | "error") => void;
|
||||
}
|
||||
|
||||
function getAgentLabel(agent: Agent): string {
|
||||
const base = agent.name?.trim() || agent.id;
|
||||
return `${base} (${agent.role})`;
|
||||
}
|
||||
|
||||
export function QuickChatFAB({ projectId, addToast }: QuickChatFABProps) {
|
||||
const { agents } = useAgents(projectId);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isConversationLoading, setIsConversationLoading] = useState(false);
|
||||
const [messageInput, setMessageInput] = useState("");
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const fabRef = useRef<HTMLButtonElement | null>(null);
|
||||
const messagesRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (agents.length === 0) {
|
||||
setSelectedAgentId("");
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedStillExists = agents.some((agent) => agent.id === selectedAgentId);
|
||||
if (!selectedStillExists) {
|
||||
setSelectedAgentId(agents[0]?.id ?? "");
|
||||
}
|
||||
}, [agents, selectedAgentId]);
|
||||
|
||||
const selectedAgent = useMemo(
|
||||
() => agents.find((agent) => agent.id === selectedAgentId) ?? null,
|
||||
[agents, selectedAgentId],
|
||||
);
|
||||
|
||||
const loadConversation = useCallback(async (agentId: string) => {
|
||||
if (!agentId) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConversationLoading(true);
|
||||
try {
|
||||
const conversation = await fetchConversation(agentId, "agent", projectId);
|
||||
setMessages(conversation);
|
||||
} catch {
|
||||
addToast("Failed to load conversation", "error");
|
||||
setMessages([]);
|
||||
} finally {
|
||||
setIsConversationLoading(false);
|
||||
}
|
||||
}, [addToast, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !selectedAgentId) return;
|
||||
void loadConversation(selectedAgentId);
|
||||
}, [isOpen, selectedAgentId, loadConversation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleDocumentClick = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (panelRef.current?.contains(target)) return;
|
||||
if (fabRef.current?.contains(target)) return;
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleDocumentClick);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleDocumentClick);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const messagesEl = messagesRef.current;
|
||||
if (!messagesEl) return;
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}, [messages, isOpen]);
|
||||
|
||||
const handleSendMessage = useCallback(async () => {
|
||||
const trimmed = messageInput.trim();
|
||||
if (!selectedAgentId || !trimmed || isSending) return;
|
||||
|
||||
setIsSending(true);
|
||||
try {
|
||||
await sendMessage(
|
||||
{
|
||||
toId: selectedAgentId,
|
||||
toType: "agent",
|
||||
content: trimmed,
|
||||
type: "user-to-agent",
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
setMessageInput("");
|
||||
await loadConversation(selectedAgentId);
|
||||
} catch {
|
||||
addToast("Failed to send message", "error");
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
}, [addToast, isSending, loadConversation, messageInput, projectId, selectedAgentId]);
|
||||
|
||||
const handleInputKeyDown = useCallback((event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== "Enter" || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
void handleSendMessage();
|
||||
}, [handleSendMessage]);
|
||||
|
||||
if (agents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{isOpen && (
|
||||
<div className="quick-chat-panel" ref={panelRef} data-testid="quick-chat-panel">
|
||||
<div className="quick-chat-panel-header">
|
||||
<h3>Quick Chat</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
aria-label="Close quick chat"
|
||||
data-testid="quick-chat-close"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="quick-chat-panel-agent-select">
|
||||
<label htmlFor="quick-chat-agent-select" className="visually-hidden">Select agent</label>
|
||||
<select
|
||||
id="quick-chat-agent-select"
|
||||
value={selectedAgentId}
|
||||
onChange={(event) => setSelectedAgentId(event.target.value)}
|
||||
data-testid="quick-chat-agent-select"
|
||||
>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{getAgentLabel(agent)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="quick-chat-panel-messages" ref={messagesRef} data-testid="quick-chat-messages">
|
||||
{isConversationLoading ? (
|
||||
<div className="quick-chat-panel-empty">Loading conversation…</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="quick-chat-panel-empty">No messages yet. Start the conversation!</div>
|
||||
) : (
|
||||
messages.map((message) => {
|
||||
const isSent = message.fromType === "user";
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`quick-chat-panel-message ${isSent ? "quick-chat-panel-message--sent" : "quick-chat-panel-message--received"}`}
|
||||
data-testid={`quick-chat-message-${message.id}`}
|
||||
>
|
||||
<p>{message.content}</p>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="quick-chat-panel-input">
|
||||
<input
|
||||
type="text"
|
||||
value={messageInput}
|
||||
onChange={(event) => setMessageInput(event.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={selectedAgent ? `Message ${selectedAgent.name || selectedAgent.id}` : "Type a message"}
|
||||
disabled={!selectedAgentId || isSending}
|
||||
data-testid="quick-chat-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSendMessage()}
|
||||
disabled={!selectedAgentId || messageInput.trim().length === 0 || isSending}
|
||||
data-testid="quick-chat-send"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { Message } from "@fusion/core";
|
||||
import type { Agent } from "../../api";
|
||||
import * as apiModule from "../../api";
|
||||
import { useAgents } from "../../hooks/useAgents";
|
||||
import { QuickChatFAB } from "../QuickChatFAB";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchConversation: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useAgents", () => ({
|
||||
useAgents: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchConversation = vi.mocked(apiModule.fetchConversation);
|
||||
const mockSendMessage = vi.mocked(apiModule.sendMessage);
|
||||
const mockUseAgents = vi.mocked(useAgents);
|
||||
|
||||
const mockAgents: Agent[] = [
|
||||
{
|
||||
id: "agent-001",
|
||||
name: "Agent One",
|
||||
role: "executor",
|
||||
state: "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
id: "agent-002",
|
||||
name: "Agent Two",
|
||||
role: "reviewer",
|
||||
state: "terminated",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
];
|
||||
|
||||
const mockConversation: Message[] = [
|
||||
{
|
||||
id: "msg-001",
|
||||
fromId: "agent-001",
|
||||
fromType: "agent",
|
||||
toId: "dashboard",
|
||||
toType: "user",
|
||||
content: "Hello from the agent",
|
||||
type: "agent-to-user",
|
||||
read: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "msg-002",
|
||||
fromId: "dashboard",
|
||||
fromType: "user",
|
||||
toId: "agent-001",
|
||||
toType: "agent",
|
||||
content: "Hello back",
|
||||
type: "user-to-agent",
|
||||
read: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
function mockAgentsHook(agents: Agent[], isLoading = false) {
|
||||
mockUseAgents.mockReturnValue({
|
||||
agents,
|
||||
activeAgents: agents.filter((agent) => agent.state === "active" || agent.state === "running"),
|
||||
stats: null,
|
||||
isLoading,
|
||||
loadAgents: vi.fn(),
|
||||
loadStats: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("QuickChatFAB", () => {
|
||||
const addToast = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAgentsHook(mockAgents);
|
||||
mockFetchConversation.mockResolvedValue(mockConversation);
|
||||
mockSendMessage.mockResolvedValue({
|
||||
id: "msg-003",
|
||||
fromId: "dashboard",
|
||||
fromType: "user",
|
||||
toId: "agent-001",
|
||||
toType: "agent",
|
||||
content: "New message",
|
||||
type: "user-to-agent",
|
||||
read: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it("renders nothing when no agents exist", () => {
|
||||
mockAgentsHook([]);
|
||||
|
||||
render(<QuickChatFAB addToast={addToast} />);
|
||||
|
||||
expect(screen.queryByTestId("quick-chat-fab")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders FAB button when agents exist", () => {
|
||||
render(<QuickChatFAB addToast={addToast} />);
|
||||
|
||||
expect(screen.getByTestId("quick-chat-fab")).toBeDefined();
|
||||
});
|
||||
|
||||
it("opens chat panel when FAB is clicked", async () => {
|
||||
render(<QuickChatFAB addToast={addToast} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes panel via close button and Escape key", async () => {
|
||||
render(<QuickChatFAB addToast={addToast} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-close"));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-chat-panel")).toBeNull();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-chat-panel")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows available agents in selector", async () => {
|
||||
render(<QuickChatFAB addToast={addToast} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
const select = await screen.findByTestId("quick-chat-agent-select");
|
||||
expect(select).toBeDefined();
|
||||
expect(screen.getByRole("option", { name: "Agent One (executor)" })).toBeDefined();
|
||||
expect(screen.getByRole("option", { name: "Agent Two (reviewer)" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("sending a message calls sendMessage API with expected params", async () => {
|
||||
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
const input = await screen.findByTestId("quick-chat-input");
|
||||
fireEvent.change(input, { target: { value: "Ship it" } });
|
||||
fireEvent.click(screen.getByTestId("quick-chat-send"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendMessage).toHaveBeenCalledWith(
|
||||
{
|
||||
toId: "agent-001",
|
||||
toType: "agent",
|
||||
content: "Ship it",
|
||||
type: "user-to-agent",
|
||||
},
|
||||
"proj-123",
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByTestId("quick-chat-input") as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("switching agents loads the selected conversation", async () => {
|
||||
mockFetchConversation.mockResolvedValue([]);
|
||||
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchConversation).toHaveBeenCalledWith("agent-001", "agent", "proj-123");
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId("quick-chat-agent-select"), {
|
||||
target: { value: "agent-002" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchConversation).toHaveBeenCalledWith("agent-002", "agent", "proj-123");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows placeholder text when conversation is empty", async () => {
|
||||
mockFetchConversation.mockResolvedValue([]);
|
||||
render(<QuickChatFAB addToast={addToast} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No messages yet. Start the conversation!")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes panel when clicking outside", async () => {
|
||||
render(<QuickChatFAB addToast={addToast} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.mouseDown(document.body);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-chat-panel")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22689,3 +22689,183 @@ html .column.drag-over * {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Quick Chat FAB ──────────────────────────────────────────────── */
|
||||
|
||||
.quick-chat-fab {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: calc(24px + var(--executor-footer-height, 0px));
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid color-mix(in srgb, var(--todo) 45%, var(--border));
|
||||
background: var(--todo);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8px 20px color-mix(in srgb, var(--todo) 35%, rgba(0, 0, 0, 0.45));
|
||||
cursor: pointer;
|
||||
z-index: 1000;
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast), filter var(--transition-fast);
|
||||
}
|
||||
|
||||
.quick-chat-fab:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 24px color-mix(in srgb, var(--todo) 40%, rgba(0, 0, 0, 0.5));
|
||||
filter: brightness(1.04);
|
||||
}
|
||||
|
||||
.quick-chat-fab:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.quick-chat-fab--hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.quick-chat-panel {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: calc(84px + var(--executor-footer-height, 0px));
|
||||
width: 320px;
|
||||
height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.quick-chat-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--surface) 88%, var(--card));
|
||||
}
|
||||
|
||||
.quick-chat-panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.quick-chat-panel-agent-select {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.quick-chat-panel-agent-select select {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
.quick-chat-panel-messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
background: color-mix(in srgb, var(--bg) 35%, transparent);
|
||||
}
|
||||
|
||||
.quick-chat-panel-empty {
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.quick-chat-panel-message {
|
||||
max-width: 86%;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.quick-chat-panel-message p {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.quick-chat-panel-message--sent {
|
||||
align-self: flex-end;
|
||||
background: color-mix(in srgb, var(--todo) 20%, transparent);
|
||||
border-color: color-mix(in srgb, var(--todo) 45%, var(--border));
|
||||
}
|
||||
|
||||
.quick-chat-panel-message--received {
|
||||
align-self: flex-start;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.quick-chat-panel-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--surface) 85%, var(--bg));
|
||||
}
|
||||
|
||||
.quick-chat-panel-input input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.quick-chat-panel-input button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid color-mix(in srgb, var(--todo) 45%, var(--border));
|
||||
background: var(--todo);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.quick-chat-panel-input button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.quick-chat-fab {
|
||||
right: 16px;
|
||||
bottom: calc(16px + var(--executor-footer-height-mobile, var(--executor-footer-height, 0px)));
|
||||
}
|
||||
|
||||
.quick-chat-panel {
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
width: auto;
|
||||
bottom: calc(72px + var(--executor-footer-height-mobile, var(--executor-footer-height, 0px)));
|
||||
height: min(420px, calc(100dvh - 160px));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user