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:
Nexus Dev 2026-04-01 18:35:07 +00:00
parent 954e7819c6
commit 8edee91d26
2 changed files with 255 additions and 47 deletions

View file

@ -1,18 +1,40 @@
import { useEffect, useRef, useState } from "react";
import { Send, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ChatSlashCommandPopover } from "./ChatSlashCommandPopover";
import { ChatMentionPopover } from "./ChatMentionPopover";
import { cn } from "../lib/utils";
import type { Agent } from "@paperclipai/shared";
interface ChatInputProps {
onSend: (content: string) => void;
isSubmitting?: 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 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
useEffect(() => {
const el = textareaRef.current;
@ -26,27 +48,96 @@ export function ChatInput({ onSend, isSubmitting = false, disabled = false }: Ch
if (!trimmed || isSubmitting || disabled) return;
onSend(trimmed);
setValue("");
setSlashOpen(false);
setMentionOpen(false);
if (textareaRef.current) {
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>) {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
submit();
} else if (e.key === "Escape") {
if (e.key === "Escape") {
if (slashOpen) {
setSlashOpen(false);
return;
}
if (mentionOpen) {
setMentionOpen(false);
return;
}
e.preventDefault();
setValue("");
if (textareaRef.current) {
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)
}
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 (
<form
onSubmit={(e) => {
@ -55,24 +146,27 @@ export function ChatInput({ onSend, isSubmitting = false, disabled = false }: Ch
}}
className="flex items-end gap-2"
>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Message your agent..."
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]",
)}
/>
{/* Slash command takes priority over mention popover */}
<div className="flex-1 relative">
<ChatSlashCommandPopover
open={slashOpen && !mentionOpen}
onOpenChange={setSlashOpen}
onSelect={handleSlashSelect}
query={slashQuery}
>
<ChatMentionPopover
open={mentionOpen && !slashOpen}
onOpenChange={setMentionOpen}
onSelect={handleMentionSelect}
query={mentionQuery}
agents={agents}
isLoading={agentsLoading}
>
{textarea}
</ChatMentionPopover>
</ChatSlashCommandPopover>
</div>
<Button
type="submit"
variant="ghost"

View file

@ -1,65 +1,158 @@
import { useState } from "react";
import { useState, useMemo } from "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 { useCompany } from "../context/CompanyContext";
import { ChatInput } from "./ChatInput";
import { ChatConversationList } from "./ChatConversationList";
import { ChatMessageList } from "./ChatMessageList";
import { ChatAgentSelector } from "./ChatAgentSelector";
import { ChatStopButton } from "./ChatStopButton";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { chatApi } from "../api/chat";
import { agentsApi } from "../api/agents";
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() {
const { chatOpen, setChatOpen, activeConversationId, setActiveConversationId } = useChatPanel();
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
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) => {
if (!selectedCompanyId) return;
// Resolve agent from slash command or @mention
const resolvedAgentId = resolveAgentFromContent(content, agents, activeAgentId);
setIsSending(true);
try {
if (!activeConversationId) {
// Path 1: No active conversation -- create one first via direct API call
const newConvo = await chatApi.createConversation(selectedCompanyId, {});
// Path 1: No active conversation -- create one, post user message, then stream
const newConvo = await chatApi.createConversation(selectedCompanyId, {
agentId: resolvedAgentId ?? undefined,
});
setActiveConversationId(newConvo.id);
await chatApi.postMessage(newConvo.id, { role: "user", content });
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 {
// Path 2: Active conversation -- use hook mutation for automatic invalidation
await sendMutation.mutateAsync({ content });
// Path 2: Active conversation -- post user message then stream
await chatApi.postMessage(activeConversationId, { role: "user", content });
queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] });
startStream(content, resolvedAgentId ?? undefined);
}
} finally {
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 (
<aside
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"
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]">
<span className="text-sm font-medium">Chat</span>
<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 className="flex items-center gap-2">
{selectedCompanyId && (
<ChatAgentSelector
companyId={selectedCompanyId}
conversationId={activeConversationId}
agentId={activeAgentId}
onAgentChange={setActiveAgentId}
/>
)}
<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>
{/* Two-column layout: conversation list (left) + thread (right) */}
{/* Two-column layout */}
<div className="flex flex-1 min-h-0 min-w-[380px]">
{/* Left column: conversation list */}
<div className="w-[160px] flex-shrink-0 border-r border-border bg-card overflow-hidden">
@ -72,11 +165,22 @@ export function ChatPanel() {
)}
</div>
{/* Right column: message thread + input */}
{/* Right column: message thread + stop button + input */}
<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 ? (
<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">
<p className="text-sm text-muted-foreground text-center">
@ -84,11 +188,21 @@ export function ChatPanel() {
</p>
</div>
)}
</ScrollArea>
</div>
{/* Stop button (shown during streaming) */}
{isStreaming && <ChatStopButton onStop={stop} />}
{/* Input area */}
<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>