From 8edee91d26b9f4029fdbdc2ebedebb496cf15dcf Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Wed, 1 Apr 2026 18:35:07 +0000 Subject: [PATCH] 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) --- ui/src/components/ChatInput.tsx | 140 ++++++++++++++++++++++----- ui/src/components/ChatPanel.tsx | 162 +++++++++++++++++++++++++++----- 2 files changed, 255 insertions(+), 47 deletions(-) diff --git a/ui/src/components/ChatInput.tsx b/ui/src/components/ChatInput.tsx index e0a13314..be2532e5 100644 --- a/ui/src/components/ChatInput.tsx +++ b/ui/src/components/ChatInput.tsx @@ -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(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) { + 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) { - 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 = ( +