From a292920813d2d33fa166b6df742f71c7d7ccd492 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 4 Apr 2026 06:48:56 -0500 Subject: [PATCH] chat tweaks - add page title - scroll if near bottom - add tool call group that dynamically updates as tool calls are made - add bouncing loading indicator and other UI polish --- web/public/locales/en/views/chat.json | 1 + web/src/components/chat/ChatMessage.tsx | 78 ++++---- web/src/components/chat/ChatStartingState.tsx | 2 +- web/src/components/chat/ToolCallBubble.tsx | 8 +- web/src/components/chat/ToolCallsGroup.tsx | 56 ++++++ web/src/pages/Chat.tsx | 178 ++++++++++-------- 6 files changed, 205 insertions(+), 118 deletions(-) create mode 100644 web/src/components/chat/ToolCallsGroup.tsx diff --git a/web/public/locales/en/views/chat.json b/web/public/locales/en/views/chat.json index bafe488a6b..ca0520d88e 100644 --- a/web/public/locales/en/views/chat.json +++ b/web/public/locales/en/views/chat.json @@ -1,4 +1,5 @@ { + "documentTitle": "Chat - Frigate", "title": "Frigate Chat", "subtitle": "Your AI assistant for camera management and insights", "placeholder": "Ask anything...", diff --git a/web/src/components/chat/ChatMessage.tsx b/web/src/components/chat/ChatMessage.tsx index a644a9d7d1..b21fae4355 100644 --- a/web/src/components/chat/ChatMessage.tsx +++ b/web/src/components/chat/ChatMessage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { useTranslation } from "react-i18next"; @@ -6,6 +6,7 @@ import copy from "copy-to-clipboard"; import { toast } from "sonner"; import { FaCopy, FaPencilAlt } from "react-icons/fa"; import { FaArrowUpLong } from "react-icons/fa6"; +import { LuCheck } from "react-icons/lu"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { @@ -50,13 +51,17 @@ export function MessageBubble({ } }, [isEditing]); - const handleCopy = () => { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { const text = content?.trim() || ""; if (!text) return; if (copy(text)) { + setCopied(true); toast.success(t("button.copiedToClipboard", { ns: "common" })); + setTimeout(() => setCopied(false), 2000); } - }; + }, [content, t]); const handleEditClick = () => { setDraftContent(content); @@ -93,7 +98,7 @@ export function MessageBubble({ value={draftContent} onChange={(e) => setDraftContent(e.target.value)} onKeyDown={handleEditKeyDown} - className="min-h-[80px] w-full resize-y rounded-lg bg-primary px-3 py-2 text-primary-foreground placeholder:text-primary-foreground/60" + className="min-h-[80px] w-full resize-y rounded-2xl bg-primary px-4 py-3 text-primary-foreground placeholder:text-primary-foreground/60" placeholder={t("placeholder")} rows={3} /> @@ -124,44 +129,49 @@ export function MessageBubble({ return (
{isUser ? ( content ) : ( - ( - - ), - th: ({ node: _n, ...props }) => ( -
- ), - td: ({ node: _n, ...props }) => ( - - ), - }} - > - {content} - + <> + ( + + ), + th: ({ node: _n, ...props }) => ( +
+ ), + td: ({ node: _n, ...props }) => ( + + ), + }} + > + {content} + + {!isComplete && ( + + )} + )}
@@ -194,7 +204,11 @@ export function MessageBubble({ disabled={!content?.trim()} aria-label={t("button.copy", { ns: "common" })} > - + {copied ? ( + + ) : ( + + )} diff --git a/web/src/components/chat/ChatStartingState.tsx b/web/src/components/chat/ChatStartingState.tsx index d2309223f1..e6b611bf9a 100644 --- a/web/src/components/chat/ChatStartingState.tsx +++ b/web/src/components/chat/ChatStartingState.tsx @@ -75,7 +75,7 @@ export function ChatStartingState({ onSendMessage }: ChatStartingStateProps) {
-
+
{open ? ( - + ) : ( - + )} {isLeft ? t("call") : t("result")} {normalizedName} diff --git a/web/src/components/chat/ToolCallsGroup.tsx b/web/src/components/chat/ToolCallsGroup.tsx new file mode 100644 index 0000000000..72a700a580 --- /dev/null +++ b/web/src/components/chat/ToolCallsGroup.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { LuChevronDown, LuChevronRight } from "react-icons/lu"; +import { ToolCallBubble } from "@/components/chat/ToolCallBubble"; +import type { ToolCall } from "@/types/chat"; + +type ToolCallsGroupProps = { + toolCalls: ToolCall[]; +}; + +export function ToolCallsGroup({ toolCalls }: ToolCallsGroupProps) { + const { t } = useTranslation(["views/chat"]); + const [open, setOpen] = useState(false); + + if (toolCalls.length === 0) return null; + + return ( + + + {open ? ( + + ) : ( + + )} + + {open ? t("hideTools") : t("showTools", { count: toolCalls.length })} + + + +
+ {toolCalls.map((tc, tcIdx) => ( +
+ + {tc.response && ( + + )} +
+ ))} +
+
+
+ ); +} diff --git a/web/src/pages/Chat.tsx b/web/src/pages/Chat.tsx index 4acfc96dea..bd154f1a40 100644 --- a/web/src/pages/Chat.tsx +++ b/web/src/pages/Chat.tsx @@ -1,12 +1,13 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { FaArrowUpLong } from "react-icons/fa6"; +import { LuCircleAlert } from "react-icons/lu"; import { useTranslation } from "react-i18next"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useRef, useEffect } from "react"; import axios from "axios"; import { ChatEventThumbnailsRow } from "@/components/chat/ChatEventThumbnailsRow"; import { MessageBubble } from "@/components/chat/ChatMessage"; -import { ToolCallBubble } from "@/components/chat/ToolCallBubble"; +import { ToolCallsGroup } from "@/components/chat/ToolCallsGroup"; import { ChatStartingState } from "@/components/chat/ChatStartingState"; import type { ChatMessage } from "@/types/chat"; import { @@ -20,6 +21,21 @@ export default function ChatPage() { const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const scrollRef = useRef(null); + + useEffect(() => { + document.title = t("documentTitle"); + }, [t]); + + // Auto-scroll to bottom when messages change, but only if near bottom + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150; + if (isNearBottom) { + el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); + } + }, [messages]); const submitConversation = useCallback( async (messagesToSend: ChatMessage[]) => { @@ -77,7 +93,7 @@ export default function ChatPage() { ); return ( -
+
{messages.length === 0 ? ( ) : ( -
- {messages.map((msg, i) => { - const isStreamingPlaceholder = - i === messages.length - 1 && - msg.role === "assistant" && - isLoading && - !msg.content?.trim() && - !(msg.toolCalls && msg.toolCalls.length > 0); - if (isStreamingPlaceholder) { - return
; - } - return ( -
- {msg.role === "assistant" && msg.toolCalls && ( - <> - {msg.toolCalls.map((tc, tcIdx) => ( -
- - {tc.response && ( - - )} -
- ))} - - )} - - {msg.role === "assistant" && - (() => { - const isComplete = !isLoading || i < messages.length - 1; - if (!isComplete) return null; - const events = getEventIdsFromSearchObjectsToolCalls( - msg.toolCalls, - ); - return ; - })()} -
- ); - })} - {(() => { - const lastMsg = messages[messages.length - 1]; - const showProcessing = - isLoading && - lastMsg?.role === "assistant" && - !lastMsg.content?.trim() && - !(lastMsg.toolCalls && lastMsg.toolCalls.length > 0); - return showProcessing ? ( -
- {t("processing")} -
- ) : null; - })()} - {error && ( -

- {error} -

- )} -
+ <> +
+ {messages.map((msg, i) => { + const isLastAssistant = + i === messages.length - 1 && msg.role === "assistant"; + const isComplete = + msg.role === "user" || !isLoading || !isLastAssistant; + const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0; + const hasContent = !!msg.content?.trim(); + const showProcessing = + isLastAssistant && isLoading && !hasContent; + + // Hide empty placeholder only when there are no tool calls yet + if ( + isLastAssistant && + isLoading && + !hasContent && + !hasToolCalls + ) + return ( +
+ + + +
+ ); + + return ( +
+ {msg.role === "assistant" && hasToolCalls && ( + + )} + {showProcessing ? ( +
+ + + +
+ ) : ( + + )} + {msg.role === "assistant" && + isComplete && + (() => { + const events = getEventIdsFromSearchObjectsToolCalls( + msg.toolCalls, + ); + return ; + })()} +
+ ); + })} + {error && ( +

+ + {error} +

+ )} +
+ )} {messages.length > 0 && ( +