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 && (
+