diff --git a/package.json b/package.json
index e078c61..9573c9b 100644
--- a/package.json
+++ b/package.json
@@ -34,12 +34,14 @@
"esbuild-plugin-tailwindcss": "^2.0.1",
"framer-motion": "^12.4.9",
"lucide-react": "^0.476.0",
+ "next-themes": "^0.4.4",
"prettier": "^3.5.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.0.1",
"react-router-dom": "^6.17.0",
"remark-gfm": "^4.0.1",
+ "sonner": "^2.0.1",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"use-query-params": "^2.2.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3f3b32f..33630c9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -78,6 +78,9 @@ importers:
lucide-react:
specifier: ^0.476.0
version: 0.476.0(react@19.0.0)
+ next-themes:
+ specifier: ^0.4.4
+ version: 0.4.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
prettier:
specifier: ^3.5.2
version: 3.5.2
@@ -96,6 +99,9 @@ importers:
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
+ sonner:
+ specifier: ^2.0.1
+ version: 2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
tailwind-merge:
specifier: ^3.0.2
version: 3.0.2
@@ -3891,6 +3897,15 @@ packages:
integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==,
}
+ next-themes@0.4.4:
+ resolution:
+ {
+ integrity: sha512-LDQ2qIOJF0VnuVrrMSMLrWGjRMkq+0mpgl6e0juCLqdJ+oo8Q84JRWT6Wh11VDQKkMMe+dVzDKLWs5n87T+PkQ==,
+ }
+ peerDependencies:
+ react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
+
node-domexception@1.0.0:
resolution:
{
@@ -4490,6 +4505,15 @@ packages:
integrity: sha512-xMO/8eNREtaROt7tJvWJqHBDTMFN4eiQ5I4JRMuilwfnFcV5W9u7RUkueNkdw0jPqGMX36iCywelS5yilTuOxg==,
}
+ sonner@2.0.1:
+ resolution:
+ {
+ integrity: sha512-FRBphaehZ5tLdLcQ8g2WOIRE+Y7BCfWi5Zyd8bCvBjiW8TxxAyoWZIxS661Yz6TGPqFQ4VLzOF89WEYhfynSFQ==,
+ }
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+
source-map-js@1.2.1:
resolution:
{
@@ -7508,6 +7532,11 @@ snapshots:
natural-compare@1.4.0: {}
+ next-themes@0.4.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
+ dependencies:
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+
node-domexception@1.0.0: {}
node-fetch@2.7.0:
@@ -7899,6 +7928,11 @@ snapshots:
simple-wcswidth@1.0.1: {}
+ sonner@2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
+ dependencies:
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+
source-map-js@1.2.1: {}
space-separated-tokens@2.0.2: {}
diff --git a/src/components/thread/history/index.tsx b/src/components/thread/history/index.tsx
index af545fb..6bd8b9d 100644
--- a/src/components/thread/history/index.tsx
+++ b/src/components/thread/history/index.tsx
@@ -21,7 +21,7 @@ function ThreadList({
const [threadId, setThreadId] = useQueryParam("threadId", StringParam);
return (
-
+
{threads.map((t) => {
let itemText = t.thread_id;
if (
diff --git a/src/components/thread/index.tsx b/src/components/thread/index.tsx
index 2855368..0acbc07 100644
--- a/src/components/thread/index.tsx
+++ b/src/components/thread/index.tsx
@@ -23,6 +23,7 @@ import {
import { BooleanParam, StringParam, useQueryParam } from "use-query-params";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
import ThreadHistory from "./history";
+import { toast } from "sonner";
function StickyToBottomContent(props: {
content: ReactNode;
@@ -75,6 +76,36 @@ export function Thread() {
const messages = stream.messages;
const isLoading = stream.isLoading;
+ const lastError = useRef
(undefined);
+
+ useEffect(() => {
+ if (!stream.error) {
+ lastError.current = undefined;
+ return;
+ }
+ try {
+ const message = (stream.error as any).message;
+ if (!message || lastError.current === message) {
+ // Message has already been logged. do not modify ref, return early.
+ return;
+ }
+
+ // Message is defined, and it has not been logged yet. Save it, and send the error
+ lastError.current = message;
+ toast.error("An error occurred. Please try again.", {
+ description: (
+
+ Error: {message}
+
+ ),
+ richColors: true,
+ closeButton: true,
+ });
+ } catch {
+ // no-op
+ }
+ }, [stream.error]);
+
// TODO: this should be part of the useStream hook
const prevMessageLength = useRef(0);
useEffect(() => {
@@ -178,7 +209,7 @@ export function Thread() {
);
diff --git a/src/components/thread/messages/ai.tsx b/src/components/thread/messages/ai.tsx
index 70579d4..e269a63 100644
--- a/src/components/thread/messages/ai.tsx
+++ b/src/components/thread/messages/ai.tsx
@@ -6,6 +6,7 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { MarkdownText } from "../markdown-text";
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui/client";
import { cn } from "@/lib/utils";
+import { ToolCalls } from "./tool-calls";
function CustomComponent({
message,
@@ -56,12 +57,18 @@ export function AssistantMessage({
const meta = thread.getMessagesMetadata(message);
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
+ const hasToolCalls =
+ "tool_calls" in message &&
+ message.tool_calls &&
+ message.tool_calls.length > 0;
+
return (
A
+ {hasToolCalls &&
}
{contentString.length > 0 && (
diff --git a/src/components/thread/messages/tool-calls.tsx b/src/components/thread/messages/tool-calls.tsx
new file mode 100644
index 0000000..741b297
--- /dev/null
+++ b/src/components/thread/messages/tool-calls.tsx
@@ -0,0 +1,53 @@
+import { AIMessage } from "@langchain/langgraph-sdk";
+
+function isComplexValue(value: any): boolean {
+ return Array.isArray(value) || (typeof value === "object" && value !== null);
+}
+
+export function ToolCalls({
+ toolCalls,
+}: {
+ toolCalls: AIMessage["tool_calls"];
+}) {
+ if (!toolCalls || toolCalls.length === 0) return null;
+
+ return (
+
+ {toolCalls.map((tc, idx) => {
+ const args = tc.args as Record
;
+ if (!tc.args || Object.keys(args).length === 0) return null;
+
+ return (
+
+
+
{tc.name}
+
+
+
+ {Object.entries(args).map(([key, value], argIdx) => (
+
+ |
+ {key}
+ |
+
+ {isComplexValue(value) ? (
+
+ {JSON.stringify(value, null, 2)}
+
+ ) : (
+ String(value)
+ )}
+ |
+
+ ))}
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx
new file mode 100644
index 0000000..787ca96
--- /dev/null
+++ b/src/components/ui/sonner.tsx
@@ -0,0 +1,27 @@
+import { useTheme } from "next-themes";
+import { Toaster as Sonner, ToasterProps } from "sonner";
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme();
+
+ return (
+
+ );
+};
+
+export { Toaster };
diff --git a/src/main.tsx b/src/main.tsx
index ce2eeaf..d81f850 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -5,6 +5,7 @@ import { StreamProvider } from "./providers/Stream.tsx";
import { QueryParamProvider } from "use-query-params";
import { ReactRouter6Adapter } from "use-query-params/adapters/react-router-6";
import { BrowserRouter } from "react-router-dom";
+import { Toaster } from "@/components/ui/sonner";
createRoot(document.getElementById("root")!).render(
@@ -13,5 +14,6 @@ createRoot(document.getElementById("root")!).render(
+
,
);
diff --git a/src/providers/Stream.tsx b/src/providers/Stream.tsx
index b896d40..df45fb4 100644
--- a/src/providers/Stream.tsx
+++ b/src/providers/Stream.tsx
@@ -14,8 +14,10 @@ import { ArrowRight } from "lucide-react";
import { PasswordInput } from "@/components/ui/password-input";
import { getApiKey } from "@/lib/api-key";
+export type StateType = { messages: Message[]; ui?: UIMessage[] };
+
const useTypedStream = useStream<
- { messages: Message[]; ui?: UIMessage[] },
+ StateType,
{
UpdateType: {
messages?: Message[] | Message | string;