feat(22-05): wire ChatPanel and ChatInput with all Phase 22 features
- ChatPanel integrates useStreamingChat, ChatAgentSelector, ChatStopButton - Agent routing via resolveAgentFromContent for slash commands and @mentions - handleEdit: editMessage + truncateMessagesAfter + re-stream with edited content - handleRetry: finds actual prior user message, truncates from user message onward, re-streams - Build agentMap from agents for message identity bars - ChatInput: slash command popover (triggered by / at start of input) - ChatInput: @mention popover (triggered by @word pattern) - Input disabled during streaming with 'Waiting for response...' placeholder - Stop button shown conditionally when isStreaming - Agent selector in header for per-conversation agent switching - Remove ScrollArea wrapper (replaced by virtualizer's own scroll in ChatMessageList)
This commit is contained in:
parent
954e7819c6
commit
8edee91d26
2 changed files with 255 additions and 47 deletions
|
|
@ -1,18 +1,40 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Send, Loader2 } from "lucide-react";
|
import { Send, Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ChatSlashCommandPopover } from "./ChatSlashCommandPopover";
|
||||||
|
import { ChatMentionPopover } from "./ChatMentionPopover";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import type { Agent } from "@paperclipai/shared";
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onSend: (content: string) => void;
|
onSend: (content: string) => void;
|
||||||
isSubmitting?: boolean;
|
isSubmitting?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
// Popover support
|
||||||
|
agents?: Agent[];
|
||||||
|
agentsLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({ onSend, isSubmitting = false, disabled = false }: ChatInputProps) {
|
export function ChatInput({
|
||||||
|
onSend,
|
||||||
|
isSubmitting = false,
|
||||||
|
disabled = false,
|
||||||
|
placeholder = "Message your agent...",
|
||||||
|
agents = [],
|
||||||
|
agentsLoading = false,
|
||||||
|
}: ChatInputProps) {
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// Slash command popover state
|
||||||
|
const [slashOpen, setSlashOpen] = useState(false);
|
||||||
|
const [slashQuery, setSlashQuery] = useState("");
|
||||||
|
|
||||||
|
// @mention popover state
|
||||||
|
const [mentionOpen, setMentionOpen] = useState(false);
|
||||||
|
const [mentionQuery, setMentionQuery] = useState("");
|
||||||
|
|
||||||
// Auto-resize fallback for browsers without field-sizing: content support
|
// Auto-resize fallback for browsers without field-sizing: content support
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = textareaRef.current;
|
const el = textareaRef.current;
|
||||||
|
|
@ -26,27 +48,96 @@ export function ChatInput({ onSend, isSubmitting = false, disabled = false }: Ch
|
||||||
if (!trimmed || isSubmitting || disabled) return;
|
if (!trimmed || isSubmitting || disabled) return;
|
||||||
onSend(trimmed);
|
onSend(trimmed);
|
||||||
setValue("");
|
setValue("");
|
||||||
|
setSlashOpen(false);
|
||||||
|
setMentionOpen(false);
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.style.height = "auto";
|
textareaRef.current.style.height = "auto";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||||
|
const val = e.target.value;
|
||||||
|
setValue(val);
|
||||||
|
|
||||||
|
// Slash command: opens when / is the first character
|
||||||
|
if (val.startsWith("/")) {
|
||||||
|
setSlashOpen(true);
|
||||||
|
setSlashQuery(val);
|
||||||
|
} else {
|
||||||
|
setSlashOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// @mention: opens when @ appears with a word boundary before it
|
||||||
|
const mentionMatch = val.match(/@(\w*)$/);
|
||||||
|
if (mentionMatch) {
|
||||||
|
setMentionOpen(true);
|
||||||
|
setMentionQuery(mentionMatch[1] ?? "");
|
||||||
|
} else {
|
||||||
|
setMentionOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSlashSelect(command: string) {
|
||||||
|
setValue(command + " ");
|
||||||
|
setSlashOpen(false);
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMentionSelect(agentName: string) {
|
||||||
|
// Replace the @query with @agentName
|
||||||
|
const val = value.replace(/@\w*$/, `@${agentName} `);
|
||||||
|
setValue(val);
|
||||||
|
setMentionOpen(false);
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
if (slashOpen) {
|
||||||
submit();
|
setSlashOpen(false);
|
||||||
} else if (e.key === "Escape") {
|
return;
|
||||||
|
}
|
||||||
|
if (mentionOpen) {
|
||||||
|
setMentionOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setValue("");
|
setValue("");
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.style.height = "auto";
|
textareaRef.current.style.height = "auto";
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
}
|
}
|
||||||
// Shift+Enter falls through to default behavior (inserts newline)
|
// Shift+Enter falls through to default behavior (inserts newline)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isEmpty = value.trim().length === 0;
|
const isEmpty = value.trim().length === 0;
|
||||||
|
|
||||||
|
const textarea = (
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={1}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm",
|
||||||
|
"placeholder:text-muted-foreground",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
"resize-none min-h-[40px] max-h-[160px] overflow-y-auto",
|
||||||
|
// field-sizing: content for modern browsers (Chrome 123+, Firefox 129+)
|
||||||
|
"[field-sizing:content]",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
|
|
@ -55,24 +146,27 @@ export function ChatInput({ onSend, isSubmitting = false, disabled = false }: Ch
|
||||||
}}
|
}}
|
||||||
className="flex items-end gap-2"
|
className="flex items-end gap-2"
|
||||||
>
|
>
|
||||||
<textarea
|
{/* Slash command takes priority over mention popover */}
|
||||||
ref={textareaRef}
|
<div className="flex-1 relative">
|
||||||
value={value}
|
<ChatSlashCommandPopover
|
||||||
onChange={(e) => setValue(e.target.value)}
|
open={slashOpen && !mentionOpen}
|
||||||
onKeyDown={handleKeyDown}
|
onOpenChange={setSlashOpen}
|
||||||
placeholder="Message your agent..."
|
onSelect={handleSlashSelect}
|
||||||
rows={1}
|
query={slashQuery}
|
||||||
disabled={disabled}
|
>
|
||||||
className={cn(
|
<ChatMentionPopover
|
||||||
"flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm",
|
open={mentionOpen && !slashOpen}
|
||||||
"placeholder:text-muted-foreground",
|
onOpenChange={setMentionOpen}
|
||||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
onSelect={handleMentionSelect}
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
query={mentionQuery}
|
||||||
"resize-none min-h-[40px] max-h-[160px] overflow-y-auto",
|
agents={agents}
|
||||||
// field-sizing: content for modern browsers (Chrome 123+, Firefox 129+)
|
isLoading={agentsLoading}
|
||||||
"[field-sizing:content]",
|
>
|
||||||
)}
|
{textarea}
|
||||||
/>
|
</ChatMentionPopover>
|
||||||
|
</ChatSlashCommandPopover>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
|
||||||
|
|
@ -1,65 +1,158 @@
|
||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useChatPanel } from "../context/ChatPanelContext";
|
import { useChatPanel } from "../context/ChatPanelContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { ChatInput } from "./ChatInput";
|
import { ChatInput } from "./ChatInput";
|
||||||
import { ChatConversationList } from "./ChatConversationList";
|
import { ChatConversationList } from "./ChatConversationList";
|
||||||
import { ChatMessageList } from "./ChatMessageList";
|
import { ChatMessageList } from "./ChatMessageList";
|
||||||
|
import { ChatAgentSelector } from "./ChatAgentSelector";
|
||||||
|
import { ChatStopButton } from "./ChatStopButton";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { chatApi } from "../api/chat";
|
import { chatApi } from "../api/chat";
|
||||||
|
import { agentsApi } from "../api/agents";
|
||||||
import { useChatMessages } from "../hooks/useChatMessages";
|
import { useChatMessages } from "../hooks/useChatMessages";
|
||||||
|
import { useStreamingChat } from "../hooks/useStreamingChat";
|
||||||
|
import { resolveAgentFromContent } from "../lib/slash-commands";
|
||||||
|
import type { AgentRole } from "@paperclipai/shared";
|
||||||
|
|
||||||
export function ChatPanel() {
|
export function ChatPanel() {
|
||||||
const { chatOpen, setChatOpen, activeConversationId, setActiveConversationId } = useChatPanel();
|
const { chatOpen, setChatOpen, activeConversationId, setActiveConversationId } = useChatPanel();
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [isSending, setIsSending] = useState(false);
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
|
||||||
|
|
||||||
const { sendMutation } = useChatMessages(activeConversationId);
|
const { messages } = useChatMessages(activeConversationId);
|
||||||
|
const { streamingContent, isStreaming, startStream, stop } = useStreamingChat(activeConversationId);
|
||||||
|
|
||||||
|
// Load agents for routing and identity
|
||||||
|
const { data: agents = [], isLoading: agentsLoading } = useQuery({
|
||||||
|
queryKey: ["agents", selectedCompanyId],
|
||||||
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build agent map for message identity bars
|
||||||
|
const agentMap = useMemo(() => {
|
||||||
|
const map = new Map<string, { name: string; icon: string | null; role: AgentRole | null }>();
|
||||||
|
for (const a of agents) {
|
||||||
|
map.set(a.id, { name: a.name, icon: a.icon, role: (a.role as AgentRole) ?? null });
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [agents]);
|
||||||
|
|
||||||
|
// Resolve streaming agent identity
|
||||||
|
const streamingAgent = activeAgentId ? agentMap.get(activeAgentId) : undefined;
|
||||||
|
|
||||||
const handleSend = async (content: string) => {
|
const handleSend = async (content: string) => {
|
||||||
if (!selectedCompanyId) return;
|
if (!selectedCompanyId) return;
|
||||||
|
|
||||||
|
// Resolve agent from slash command or @mention
|
||||||
|
const resolvedAgentId = resolveAgentFromContent(content, agents, activeAgentId);
|
||||||
|
|
||||||
setIsSending(true);
|
setIsSending(true);
|
||||||
try {
|
try {
|
||||||
if (!activeConversationId) {
|
if (!activeConversationId) {
|
||||||
// Path 1: No active conversation -- create one first via direct API call
|
// Path 1: No active conversation -- create one, post user message, then stream
|
||||||
const newConvo = await chatApi.createConversation(selectedCompanyId, {});
|
const newConvo = await chatApi.createConversation(selectedCompanyId, {
|
||||||
|
agentId: resolvedAgentId ?? undefined,
|
||||||
|
});
|
||||||
setActiveConversationId(newConvo.id);
|
setActiveConversationId(newConvo.id);
|
||||||
await chatApi.postMessage(newConvo.id, { role: "user", content });
|
await chatApi.postMessage(newConvo.id, { role: "user", content });
|
||||||
queryClient.invalidateQueries({ queryKey: ["chat"] });
|
queryClient.invalidateQueries({ queryKey: ["chat"] });
|
||||||
|
// Note: streaming starts on next render when activeConversationId is set
|
||||||
|
// For now, the echo stream will be triggered by the new conversation
|
||||||
} else {
|
} else {
|
||||||
// Path 2: Active conversation -- use hook mutation for automatic invalidation
|
// Path 2: Active conversation -- post user message then stream
|
||||||
await sendMutation.mutateAsync({ content });
|
await chatApi.postMessage(activeConversationId, { role: "user", content });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] });
|
||||||
|
startStream(content, resolvedAgentId ?? undefined);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSending(false);
|
setIsSending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Edit handler: update message, truncate after it, re-stream
|
||||||
|
const handleEdit = async (messageId: string, newContent: string) => {
|
||||||
|
if (!activeConversationId) return;
|
||||||
|
await chatApi.editMessage(activeConversationId, messageId, newContent);
|
||||||
|
await chatApi.truncateMessagesAfter(activeConversationId, messageId);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] });
|
||||||
|
startStream(newContent, activeAgentId ?? undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Retry handler: find the last user message before the assistant message,
|
||||||
|
// delete the assistant message and everything after it, then re-stream
|
||||||
|
// with the actual prior user message content (not hardcoded text).
|
||||||
|
const handleRetry = async (assistantMessageId: string) => {
|
||||||
|
if (!activeConversationId || !messages) return;
|
||||||
|
|
||||||
|
// Find the assistant message index in the messages array
|
||||||
|
const assistantIdx = messages.findIndex((m) => m.id === assistantMessageId);
|
||||||
|
if (assistantIdx < 0) return;
|
||||||
|
|
||||||
|
// Find the last user message before this assistant message
|
||||||
|
let lastUserContent = "";
|
||||||
|
for (let i = assistantIdx - 1; i >= 0; i--) {
|
||||||
|
if (messages[i]!.role === "user") {
|
||||||
|
lastUserContent = messages[i]!.content;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!lastUserContent) return; // No prior user message found; nothing to retry
|
||||||
|
|
||||||
|
// Truncate messages after the user message (this deletes the assistant msg + everything after)
|
||||||
|
// First, find the user message to truncate after
|
||||||
|
let userMessageId = "";
|
||||||
|
for (let i = assistantIdx - 1; i >= 0; i--) {
|
||||||
|
if (messages[i]!.role === "user") {
|
||||||
|
userMessageId = messages[i]!.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!userMessageId) return;
|
||||||
|
|
||||||
|
// Delete everything after the user message (includes the assistant message itself)
|
||||||
|
await chatApi.truncateMessagesAfter(activeConversationId, userMessageId);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] });
|
||||||
|
|
||||||
|
// Re-stream using the actual user message content
|
||||||
|
startStream(lastUserContent, activeAgentId ?? undefined);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-label="Chat"
|
aria-label="Chat"
|
||||||
className="hidden md:flex overflow-hidden transition-[width] duration-100 ease-out flex-shrink-0 border-l border-border flex-col bg-background"
|
className="hidden md:flex overflow-hidden transition-[width] duration-100 ease-out flex-shrink-0 border-l border-border flex-col bg-background"
|
||||||
style={{ width: chatOpen ? 380 : 0 }}
|
style={{ width: chatOpen ? 380 : 0 }}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header with agent selector */}
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-2 min-w-[380px]">
|
<div className="flex items-center justify-between border-b border-border px-4 py-2 min-w-[380px]">
|
||||||
<span className="text-sm font-medium">Chat</span>
|
<span className="text-sm font-medium">Chat</span>
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
variant="ghost"
|
{selectedCompanyId && (
|
||||||
size="icon"
|
<ChatAgentSelector
|
||||||
className="h-6 w-6"
|
companyId={selectedCompanyId}
|
||||||
onClick={() => setChatOpen(false)}
|
conversationId={activeConversationId}
|
||||||
aria-label="Close chat"
|
agentId={activeAgentId}
|
||||||
>
|
onAgentChange={setActiveAgentId}
|
||||||
<X className="h-4 w-4" />
|
/>
|
||||||
</Button>
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => setChatOpen(false)}
|
||||||
|
aria-label="Close chat"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Two-column layout: conversation list (left) + thread (right) */}
|
{/* Two-column layout */}
|
||||||
<div className="flex flex-1 min-h-0 min-w-[380px]">
|
<div className="flex flex-1 min-h-0 min-w-[380px]">
|
||||||
{/* Left column: conversation list */}
|
{/* Left column: conversation list */}
|
||||||
<div className="w-[160px] flex-shrink-0 border-r border-border bg-card overflow-hidden">
|
<div className="w-[160px] flex-shrink-0 border-r border-border bg-card overflow-hidden">
|
||||||
|
|
@ -72,11 +165,22 @@ export function ChatPanel() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right column: message thread + input */}
|
{/* Right column: message thread + stop button + input */}
|
||||||
<div className="flex flex-1 flex-col min-w-0">
|
<div className="flex flex-1 flex-col min-w-0">
|
||||||
<ScrollArea className="flex-1 p-0">
|
{/* Message area */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
{activeConversationId ? (
|
{activeConversationId ? (
|
||||||
<ChatMessageList conversationId={activeConversationId} />
|
<ChatMessageList
|
||||||
|
conversationId={activeConversationId}
|
||||||
|
streamingContent={streamingContent}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
streamingAgentName={streamingAgent?.name ?? null}
|
||||||
|
streamingAgentIcon={streamingAgent?.icon ?? null}
|
||||||
|
streamingAgentRole={streamingAgent?.role ?? null}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onRetry={handleRetry}
|
||||||
|
agentMap={agentMap}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center h-full p-3">
|
<div className="flex items-center justify-center h-full p-3">
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
|
@ -84,11 +188,21 @@ export function ChatPanel() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</div>
|
||||||
|
|
||||||
|
{/* Stop button (shown during streaming) */}
|
||||||
|
{isStreaming && <ChatStopButton onStop={stop} />}
|
||||||
|
|
||||||
{/* Input area */}
|
{/* Input area */}
|
||||||
<div className="border-t border-border px-3 py-2">
|
<div className="border-t border-border px-3 py-2">
|
||||||
<ChatInput onSend={handleSend} isSubmitting={isSending} />
|
<ChatInput
|
||||||
|
onSend={handleSend}
|
||||||
|
isSubmitting={isSending}
|
||||||
|
disabled={isStreaming}
|
||||||
|
placeholder={isStreaming ? "Waiting for response..." : "Message your agent..."}
|
||||||
|
agents={agents}
|
||||||
|
agentsLoading={agentsLoading}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue