Unverified Commit d3b4b997 authored by Daniel Hiltgen's avatar Daniel Hiltgen Committed by GitHub
Browse files

app: add code for macOS and Windows apps under 'app' (#12933)



* app: add code for macOS and Windows apps under 'app'

* app: add readme

* app: windows and linux only for now

* ci: fix ui CI validation

---------
Co-authored-by: default avatarjmorganca <jmorganca@gmail.com>
parent a4770107
import { useChats } from "@/hooks/useChats";
import { useRenameChat } from "@/hooks/useRenameChat";
import { useDeleteChat } from "@/hooks/useDeleteChat";
import { useQueryClient } from "@tanstack/react-query";
import { getChat } from "@/api";
import { Link } from "@/components/ui/link";
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { ChatsResponse } from "@/gotypes";
import { CogIcon } from "@heroicons/react/24/outline";
// there's a hidden debug feature to copy a chat's data to the clipboard by
// holding shift and clicking this many times within this many seconds
const DEBUG_SHIFT_CLICKS_REQUIRED = 5;
const DEBUG_SHIFT_CLICK_WINDOW_MS = 7000; // 7 seconds
interface ChatSidebarProps {
currentChatId?: string;
}
export function ChatSidebar({ currentChatId }: ChatSidebarProps) {
const { data, isLoading, error } = useChats();
const queryClient = useQueryClient();
const renameMutation = useRenameChat();
const deleteMutation = useDeleteChat();
const [editingChatId, setEditingChatId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const [shiftClicks, setShiftClicks] = useState<Record<string, number[]>>({});
const [copiedChatId, setCopiedChatId] = useState<string | null>(null);
const handleMouseEnter = useCallback(
(chatId: string) => {
queryClient.prefetchQuery({
queryKey: ["chat", chatId],
queryFn: () => getChat(chatId),
staleTime: 1500,
});
},
[queryClient],
);
const startEditing = useCallback((chatId: string, currentTitle: string) => {
setEditingChatId(chatId);
setEditValue(currentTitle);
}, []);
const saveRename = useCallback(async () => {
if (!editingChatId || !editValue.trim()) {
setEditingChatId(null);
return;
}
const newTitle = editValue.trim();
const chatId = editingChatId;
// Exit edit mode immediately to prevent flash
setEditingChatId(null);
setEditValue("");
// Optimistically update the cache
queryClient.setQueryData(
["chats"],
(oldData: ChatsResponse | undefined) => {
if (!oldData?.chatInfos) return oldData;
return {
...oldData,
chatInfos: oldData.chatInfos.map((chat) =>
chat.id === chatId ? { ...chat, title: newTitle } : chat,
),
};
},
);
try {
await renameMutation.mutateAsync({
chatId: chatId,
title: newTitle,
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error: unknown) {
// Revert optimistic update on error
queryClient.invalidateQueries({ queryKey: ["chats"] });
}
}, [editingChatId, editValue, renameMutation, queryClient]);
useEffect(() => {
if (editingChatId && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editingChatId]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
saveRename();
}
};
if (editingChatId) {
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}
}, [editingChatId, editValue, saveRename]);
const sortedChats = useMemo(() => {
if (!data?.chatInfos) return [];
return [...data.chatInfos].sort((a, b) => {
const comparison = b.updatedAt.getTime() - a.updatedAt.getTime();
if (comparison === 0) {
return b.id.localeCompare(a.id);
}
return comparison;
});
}, [data?.chatInfos]);
const isToday = (date: Date) => {
const today = new Date();
return (
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
);
};
const isThisWeek = (date: Date) => {
const now = new Date();
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
return date > weekAgo && !isToday(date);
};
// Group chats by time period
const groupedChats = useMemo(() => {
const groups = {
today: [] as typeof sortedChats,
thisWeek: [] as typeof sortedChats,
older: [] as typeof sortedChats,
};
sortedChats.forEach((chat) => {
if (isToday(chat.updatedAt)) {
groups.today.push(chat);
} else if (isThisWeek(chat.updatedAt)) {
groups.thisWeek.push(chat);
} else {
groups.older.push(chat);
}
});
return groups;
}, [sortedChats]);
const chatGroups = useMemo(() => {
return [
{ name: "Today", chats: groupedChats.today },
{ name: "This week", chats: groupedChats.thisWeek },
{ name: "Older", chats: groupedChats.older },
].filter((group) => group.chats.length > 0);
}, [groupedChats]);
const handleDeleteChat = useCallback(
async (chatId: string) => {
const confirmed = window.confirm(
`Are you sure you want to remove this chat?`,
);
if (!confirmed) return;
try {
await deleteMutation.mutateAsync(chatId);
} catch (error) {
console.error("Failed to delete chat:", error);
}
},
[deleteMutation],
);
// implementation of the hidden debug feature to copy a chat's data to the clipboard
const handleShiftClick = useCallback(
async (e: React.MouseEvent, chatId: string) => {
if (!e.shiftKey) return false;
e.preventDefault();
const now = Date.now();
const clicks = shiftClicks[chatId] || [];
const recentClicks = clicks.filter(
(timestamp) => now - timestamp < DEBUG_SHIFT_CLICK_WINDOW_MS,
);
recentClicks.push(now);
setShiftClicks((prev) => ({
...prev,
[chatId]: recentClicks,
}));
if (recentClicks.length >= DEBUG_SHIFT_CLICKS_REQUIRED) {
try {
const chatData = await getChat(chatId);
const jsonString = JSON.stringify(chatData, null, 2);
await navigator.clipboard.writeText(jsonString);
// visual feedback
setCopiedChatId(chatId);
setTimeout(() => setCopiedChatId(null), 2000);
setShiftClicks((prev) => ({
...prev,
[chatId]: [],
}));
} catch (error) {
console.error("Failed to copy chat data:", error);
}
}
return true;
},
[shiftClicks],
);
const handleContextMenu = useCallback(
async (_: React.MouseEvent, chatId: string, chatTitle: string) => {
const selectedAction = await window.menu([
{ label: "Rename", enabled: true },
{ label: "Delete", enabled: true },
]);
if (selectedAction === "Rename") {
startEditing(chatId, chatTitle);
} else if (selectedAction === "Delete") {
handleDeleteChat(chatId);
}
},
[startEditing, handleDeleteChat],
);
if (isLoading) {
return (
<nav className="flex min-h-0 flex-col">
<div className="flex flex-1 flex-col p-4">
<div className="p-4">Loading...</div>
</div>
</nav>
);
}
if (error) {
return (
<nav className="flex min-h-0 flex-col">
<div className="flex flex-1 flex-col p-4">
<div className="p-4 text-red-500">Error loading chats</div>
</div>
</nav>
);
}
const isWindows = navigator.platform.toLowerCase().includes("win");
return (
<nav className="flex flex-1 flex-col min-h-0 select-none">
<header className="flex flex-col gap-0.5 px-4 pb-2">
<Link
href="/c/new"
mask={{ to: "/" }}
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 dark:text-neutral-100 ${
currentChatId === "new" ? "bg-neutral-100 dark:bg-neutral-800" : ""
}`}
draggable={false}
>
<svg
className="h-5 w-5 fill-current"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M17.0859 3.39949L15.2135 5.27196H7.27028C5.78649 5.27196 4.94684 6.11336 4.94684 7.59716V16.664C4.94684 18.1558 5.78649 18.9892 7.27028 18.9892H16.3406C17.8324 18.9892 18.6623 18.1558 18.6623 16.664V8.79514L20.5428 6.9115C20.567 7.11532 20.5773 7.33066 20.5773 7.55419V16.7149C20.5773 19.4069 19.0818 20.9024 16.3898 20.9024H7.22107C4.53708 20.9024 3.03357 19.4069 3.03357 16.7149V7.55419C3.03357 4.8622 4.53708 3.35869 7.22107 3.35869H16.3898C16.6329 3.35869 16.8662 3.37094 17.0859 3.39949Z" />
<path d="M9.92714 14.381L11.914 13.5403L20.8312 4.63114L19.3404 3.1581L10.433 12.0655L9.55234 13.9964C9.45664 14.2169 9.70293 14.4714 9.92714 14.381ZM21.5767 3.89364L22.2588 3.19384C22.6347 2.80184 22.6435 2.2663 22.2711 1.90536L22.0148 1.64287C21.6822 1.31377 21.1334 1.36513 20.7689 1.72158L20.0859 2.39833L21.5767 3.89364Z" />
</svg>
<span className="truncate">New Chat</span>
</Link>
{isWindows && (
<Link
href="/settings"
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 dark:text-neutral-300`}
draggable={false}
>
<CogIcon className="h-5 w-5 stroke-current" />
<span className="truncate">Settings</span>
</Link>
)}
</header>
<div className="flex flex-1 flex-col px-4 py-1 overflow-y-auto overscroll-auto scrollbar-gutter">
<div className="flex flex-col gap-3 pt-4">
{chatGroups.map((group) => (
<div key={group.name} className="flex flex-col gap-0.5">
<h3 className="text-xs font-medium text-neutral-400 dark:text-neutral-500 px-2 py-1 select-none">
{group.name}
</h3>
{group.chats.map((chat) => (
<div
key={chat.id}
className={`allow-context-menu flex items-center relative text-sm text-neutral-800 dark:text-neutral-400 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 ${
chat.id === currentChatId
? "bg-neutral-100 text-black dark:bg-neutral-800"
: ""
}`}
onMouseEnter={() => handleMouseEnter(chat.id)}
onContextMenu={(e) =>
handleContextMenu(
e,
chat.id,
chat.title ||
chat.userExcerpt ||
chat.createdAt.toLocaleString(),
)
}
>
{editingChatId === chat.id ? (
<div className="flex-1 flex items-center min-w-0 px-2 py-2 bg-neutral-100 text-black dark:bg-neutral-800 rounded-lg">
<span className="truncate font-sans text-sm w-full">
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
saveRename();
} else if (e.key === "Escape") {
setEditingChatId(null);
setEditValue("");
}
}}
className="bg-transparent border-0 focus:outline-none w-full dark:text-white"
style={{
font: "inherit",
lineHeight: "inherit",
padding: 0,
margin: 0,
}}
/>
</span>
</div>
) : (
<Link
to="/c/$chatId"
params={{ chatId: chat.id }}
className="flex-1 flex items-center min-w-0 px-2 py-2 select-none"
onClick={(e) => {
handleShiftClick(e, chat.id);
}}
draggable={false}
>
<span className="truncate font-sans text-sm">
{chat.title ||
chat.userExcerpt ||
chat.createdAt.toLocaleString()}
</span>
{copiedChatId === chat.id && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400">
Copied!
</span>
)}
</Link>
)}
</div>
))}
</div>
))}
</div>
</div>
</nav>
);
}
import { CheckIcon } from "@heroicons/react/20/solid";
import { Square2StackIcon } from "@heroicons/react/24/outline";
import React, { useState } from "react";
interface CopyButtonProps {
content: string;
copyRef?: React.RefObject<HTMLElement | null>;
removeClasses?: string[];
size?: "sm" | "md";
showLabels?: boolean;
className?: string;
title?: string;
}
const CopyButton: React.FC<CopyButtonProps> = ({
content,
copyRef,
removeClasses = [],
size = "sm",
showLabels = false,
className = "",
title = "",
}) => {
const [isCopied, setIsCopied] = useState(false);
const handleCopy = async () => {
try {
if (copyRef?.current) {
// For copy response message
const cloned = copyRef.current.cloneNode(true) as HTMLElement;
removeClasses.forEach((className) => {
cloned
.querySelectorAll(`.${className}`)
.forEach((element) => element.remove());
});
await navigator.clipboard.write([
new ClipboardItem({
"text/html": new Blob([cloned.innerHTML], {
type: "text/html",
}),
"text/plain": new Blob([content], { type: "text/plain" }),
}),
]);
} else {
await navigator.clipboard.writeText(content);
}
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (error) {
console.error("Clipboard API failed, falling back to plain text", error);
try {
await navigator.clipboard.writeText(content);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (fallbackError) {
console.error("Fallback copy also failed:", fallbackError);
}
}
};
const iconSize = size === "sm" ? "h-3 w-3" : "h-7 w-7";
const baseClasses =
size === "sm"
? `text-xs px-4 py-2 z-10 rounded-lg hover:cursor-pointer ${className}`
: `${iconSize} px-1 py-0.5 text-xs cursor-pointer rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 flex items-center justify-center ${className}`;
const icon = isCopied ? (
<CheckIcon className={iconSize} />
) : (
<Square2StackIcon className={iconSize} />
);
return (
<button
type="button"
className={baseClasses}
onClick={handleCopy}
title={title}
>
{showLabels ? (
<span className="flex items-center gap-1">
{icon}
{isCopied ? "Copied" : "Copy"}
</span>
) : (
icon
)}
</button>
);
};
export default CopyButton;
import type { ErrorEvent } from "@/gotypes";
import { Display, type DisplayAction } from "@/components/ui/display";
import { useUser } from "@/hooks/useUser";
import { useEffect, useState } from "react";
interface DisplayLoginProps {
error: ErrorEvent | null;
className?: string;
onDismiss?: () => void;
message?: string;
}
export const DisplayLogin = ({
error,
className,
onDismiss,
message,
}: DisplayLoginProps) => {
const { fetchConnectUrl, refetchUser, isAuthenticated } = useUser();
const [isAwaitingAuth, setIsAwaitingAuth] = useState(false);
useEffect(() => {
const handleFocus = () => {
if (isAwaitingAuth) {
setIsAwaitingAuth(false);
refetchUser();
}
};
window.addEventListener("focus", handleFocus);
return () => {
window.removeEventListener("focus", handleFocus);
};
}, [isAwaitingAuth, refetchUser]);
useEffect(() => {
if (isAuthenticated && isAwaitingAuth) {
setIsAwaitingAuth(false);
if (onDismiss) {
onDismiss();
}
}
}, [isAuthenticated, isAwaitingAuth, onDismiss]);
if (!error || error.code !== "cloud_unauthorized" || isAuthenticated)
return null;
const handleSignIn = async () => {
try {
const { data: connectUrl } = await fetchConnectUrl();
if (connectUrl) {
window.open(connectUrl, "_blank");
setIsAwaitingAuth(true);
}
} catch (error) {
console.error("Error getting connect URL:", error);
}
};
const action: DisplayAction = {
label: "Sign In",
onClick: handleSignIn,
};
return (
<Display
message={message || "Cloud models require an Ollama account"}
action={action}
className={className}
onDismiss={onDismiss}
/>
);
};
import { Model } from "@/gotypes";
import { useSendMessage, useIsStreaming } from "@/hooks/useChats";
import { Display, type DisplayAction } from "@/components/ui/display";
interface DisplayStaleProps {
model: Model;
onDismiss: () => void;
className?: string;
chatId: string;
onScrollToBottom?: () => void;
}
export const DisplayStale = ({
model,
onDismiss,
className,
chatId,
onScrollToBottom,
}: DisplayStaleProps) => {
const sendMessage = useSendMessage(chatId);
const isStreaming = useIsStreaming(chatId);
const handleUpdateModel = async () => {
if (onScrollToBottom) {
onScrollToBottom();
}
try {
sendMessage.mutate({
message: "",
forceUpdate: true,
});
} catch (error) {
console.error("Failed to update model:", error);
}
onDismiss();
};
const action: DisplayAction = {
label: "Update",
onClick: handleUpdateModel,
disabled: isStreaming,
gradientColors: "from-zinc-500/20 via-slate-500/20 to-gray-500/20",
};
return (
<Display
message={`A newer version of ${model.model} is available`}
variant="zinc"
onDismiss={onDismiss}
action={action}
className={className}
/>
);
};
import type { ErrorEvent } from "@/gotypes";
import { Display, type DisplayAction } from "@/components/ui/display";
interface DisplayUpgradeProps {
error: ErrorEvent | null;
onDismiss: () => void;
className?: string;
message?: string;
label?: string;
href?: string;
}
export const DisplayUpgrade = ({
error,
onDismiss,
className,
message,
label = "Upgrade",
href = "https://ollama.com/upgrade",
}: DisplayUpgradeProps) => {
if (!error || error.code !== "usage_limit_upgrade") return null;
const isUsageLimit = error.code === "usage_limit_upgrade";
const action: DisplayAction | undefined = isUsageLimit
? {
label,
href,
gradientColors: "from-cyan-500/20 via-purple-500/20 to-green-500/20",
}
: undefined;
const variant = isUsageLimit ? "neutral" : "red";
return (
<Display
message={message || error.error || "An error occurred"}
variant={variant}
onDismiss={onDismiss}
action={action}
className={className}
/>
);
};
const K = 1024;
const SIZES = ["B", "KB", "MB", "GB", "TB"];
function formatBytes(bytes: number, unit?: string): string {
let i: number;
if (unit) {
i = SIZES.indexOf(unit);
} else {
i = bytes === 0 ? 0 : Math.floor(Math.log(bytes) / Math.log(K));
}
const decimals = SIZES[i] === "GB" || SIZES[i] === "TB" ? 1 : 0;
return `${(bytes / Math.pow(K, i)).toFixed(decimals)} ${SIZES[i]}`;
}
export default function Downloading({
completed,
total,
}: {
completed: number;
total: number;
}) {
const percentage = total > 0 ? (completed / total) * 100 : 0;
const unitIndex = total > 0 ? Math.floor(Math.log(total) / Math.log(K)) : 0;
const unit = SIZES[unitIndex];
return (
<div className="my-4 rounded-xl max-w-xs">
<div className="flex flex-col mb-2 text-neutral-800 dark:text-neutral-200">
<div className="flex items-center mb-0.5">
<svg
className="w-3.5 absolute fill-current"
viewBox="0 0 18 25"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M17.334 11.1133V18.6328C17.334 21.25 15.8691 22.7148 13.2422 22.7148H4.08203C1.45508 22.7148 0 21.25 0 18.6328V11.1133C0 8.48633 1.45508 7.03125 4.08203 7.03125H6.05469V8.60352H4.08203C2.48047 8.60352 1.57227 9.51172 1.57227 11.1133V18.6328C1.57227 20.2344 2.48047 21.1426 4.08203 21.1426H13.2422C14.8535 21.1426 15.7617 20.2344 15.7617 18.6328V11.1133C15.7617 9.51172 14.8535 8.60352 13.2422 8.60352H11.2793V7.03125H13.2422C15.8691 7.03125 17.334 8.48633 17.334 11.1133Z" />
<path d="M8.67188 1.83594C8.25195 1.83594 7.89062 2.17773 7.89062 2.58789V12.5195L8.00781 15.1562C8.02734 15.5176 8.31055 15.8105 8.67188 15.8105C9.02344 15.8105 9.30664 15.5176 9.32617 15.1562L9.44336 12.5195V2.58789C9.44336 2.17773 9.0918 1.83594 8.67188 1.83594ZM5.35156 11.5625C4.94141 11.5625 4.64844 11.8457 4.64844 12.2461C4.64844 12.4609 4.73633 12.6172 4.88281 12.7637L8.10547 15.8691C8.30078 16.0645 8.4668 16.123 8.67188 16.123C8.86719 16.123 9.0332 16.0645 9.22852 15.8691L12.4512 12.7637C12.5977 12.6172 12.6855 12.4609 12.6855 12.2461C12.6855 11.8457 12.373 11.5625 11.9727 11.5625C11.7773 11.5625 11.582 11.6406 11.4453 11.7969L9.93164 13.4082L8.67188 14.7461L7.40234 13.4082L5.88867 11.7969C5.75195 11.6406 5.54688 11.5625 5.35156 11.5625Z" />
</svg>
<div className="ml-6">Downloading model</div>
</div>
<div className="text-sm text-neutral-500 dark:text-neutral-500 ml-6">
{`${formatBytes(completed, unit)} / ${formatBytes(total, unit)} (${Math.floor(percentage)}%)`}
</div>
</div>
<div className="relative h-1.5 bg-neutral-200 dark:bg-neutral-700 rounded-full overflow-hidden ml-6">
<div
className="absolute left-0 top-0 h-full bg-neutral-700 dark:bg-neutral-500 rounded-full"
style={{
width: `${percentage}%`,
}}
/>
</div>
</div>
);
}
import type { ErrorEvent } from "@/gotypes";
interface ErrorMessageProps {
error: ErrorEvent;
}
const renderWithLinks = (text: string) => {
const urlRegex =
/(https?:\/\/(?!127\.|localhost|0\.0\.0\.0|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)[^\s]+)/g;
const urls: string[] = [];
let match;
while ((match = urlRegex.exec(text)) !== null) {
urls.push(match[0]);
}
urlRegex.lastIndex = 0;
// Split text by URLs
// for example if the text is "connection failed. Try visiting https://ollama.com or check https://ollama.com/doc for help"
// then the parsed parts will be ["connection failed. Try visiting ", "https://ollama.com", " or check ", "https://ollama.com/doc", " for help"]
const parts = text.split(urlRegex);
return parts.map((part, index) => {
if (urls.includes(part)) {
return (
<a
key={index}
href={part}
target="_blank"
rel="noopener noreferrer"
className="underline break-all"
onClick={(e) => {
e.stopPropagation();
}}
>
{part}
</a>
);
}
return part;
});
};
export const ErrorMessage = ({ error }: ErrorMessageProps) => {
return (
<div className="flex flex-col w-full text-neutral-800 dark:text-neutral-200 transition-colors mb-8">
<div className="flex items-center self-start relative">
{/* Circled X icon */}
<div className="flex-shrink-0 w-4 h-4 rounded-full border border-black dark:border-white flex items-center justify-center mr-3">
<svg
className="w-2.5 h-2.5 fill-current text-black dark:text-white"
viewBox="0 0 20 20"
fill="none"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</div>
<h3>Error</h3>
</div>
<div className="flex items-start ml-[1.8rem] mt-2">
<div className="text-sm text-neutral-500 dark:text-neutral-500 opacity-75 flex-1">
{renderWithLinks(error.error)}
</div>
</div>
</div>
);
};
import {
useState,
useCallback,
useRef,
useEffect,
type ReactNode,
} from "react";
import { DocumentPlusIcon } from "@heroicons/react/24/outline";
import type { Model } from "@/gotypes";
import { processFiles as processFilesUtil } from "@/utils/fileValidation";
interface FileUploadProps {
children: ReactNode;
onFilesAdded: (
files: Array<{ filename: string; data: Uint8Array; type?: string }>,
errors: Array<{ filename: string; error: string }>,
) => void;
selectedModel?: Model | null;
hasVisionCapability?: boolean;
validateFile?: (file: File) => { valid: boolean; error?: string };
maxFileSize?: number;
allowedExtensions?: string[];
}
export function FileUpload({
children,
onFilesAdded,
selectedModel,
hasVisionCapability = false,
validateFile,
maxFileSize = 10,
allowedExtensions,
}: FileUploadProps) {
const [isDragging, setIsDragging] = useState(false);
// Counter to track drag enter/leave events across all child elements
// Prevents flickering when dragging over child elements within the component
const dragCounter = useRef(0);
// Helper function to check if dragging files
const hasFiles = useCallback((dataTransfer: DataTransfer) => {
return dataTransfer.types.includes("Files");
}, []);
// Helper function to read directory contents
const readDirectory = useCallback(
async (entry: FileSystemDirectoryEntry): Promise<File[]> => {
const files: File[] = [];
const readEntries = async (
dirEntry: FileSystemDirectoryEntry,
): Promise<void> => {
const dirReader = dirEntry.createReader();
return new Promise((resolve, reject) => {
dirReader.readEntries(async (entries) => {
try {
for (const entry of entries) {
if (entry.isFile) {
const fileEntry = entry as FileSystemFileEntry;
const file = await new Promise<File>(
(resolveFile, rejectFile) => {
fileEntry.file(resolveFile, rejectFile);
},
);
files.push(file);
} else if (entry.isDirectory) {
// Skip subdirectories for simplicity
}
}
resolve();
} catch (error) {
reject(error);
}
}, reject);
});
};
await readEntries(entry);
return files;
},
[],
);
// Main file processing function
const processFiles = useCallback(
async (dataTransfer: DataTransfer) => {
const allFiles: File[] = [];
// Extract files from DataTransfer
if (dataTransfer.items) {
for (const item of dataTransfer.items) {
if (item.kind === "file") {
const entry = item.webkitGetAsEntry?.();
if (entry?.isFile) {
const file = item.getAsFile();
if (file) allFiles.push(file);
} else if (entry?.isDirectory) {
const dirEntry = entry as FileSystemDirectoryEntry;
const dirFiles = await readDirectory(dirEntry);
allFiles.push(...dirFiles);
}
}
}
} else if (dataTransfer.files) {
allFiles.push(...Array.from(dataTransfer.files));
}
// Use shared validation utility
const { validFiles, errors } = await processFilesUtil(allFiles, {
maxFileSize,
allowedExtensions,
hasVisionCapability,
selectedModel,
customValidator: validateFile,
});
// Send processed files and errors back to parent
if (validFiles.length > 0 || errors.length > 0) {
onFilesAdded(validFiles, errors);
}
},
[
readDirectory,
selectedModel,
hasVisionCapability,
allowedExtensions,
maxFileSize,
validateFile,
onFilesAdded,
],
);
// Drag event handlers
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current++;
if (hasFiles(e.dataTransfer)) {
setIsDragging(true);
}
},
[hasFiles],
);
// Paste event handler
const handlePaste = useCallback(
async (e: ClipboardEvent) => {
// Check if clipboard contains files
if (e.clipboardData && e.clipboardData.files.length > 0) {
// Check if there's text data in the clipboard
// Only process files if there's no text data
const hasTextData =
e.clipboardData.types.includes("text/plain") &&
e.clipboardData.getData("text/plain").trim().length > 0;
if (hasTextData) {
return;
}
e.preventDefault();
// Create a synthetic DataTransfer object for our processFiles function
const items = Array.from(e.clipboardData.items);
const syntheticDataTransfer = {
files: e.clipboardData.files,
items: {
...items,
length: items.length,
add: () => null,
clear: () => {},
remove: () => null,
[Symbol.iterator]: () => items[Symbol.iterator](),
} as DataTransferItemList,
types: e.clipboardData.types,
getData: (format: string) => e.clipboardData!.getData(format),
} as DataTransfer;
await processFiles(syntheticDataTransfer);
}
},
[processFiles],
);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current--;
// Only hide overlay when leaving all elements in the component tree
if (dragCounter.current === 0) {
setIsDragging(false);
}
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current = 0;
setIsDragging(false);
if (hasFiles(e.dataTransfer) && e.dataTransfer) {
await processFiles(e.dataTransfer);
}
},
[hasFiles, processFiles],
);
// Set up paste event listener
useEffect(() => {
document.addEventListener("paste", handlePaste);
return () => {
document.removeEventListener("paste", handlePaste);
};
}, [handlePaste]);
return (
<div
className="relative"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{children}
{/* Drop zone overlay */}
{isDragging && (
<div className="absolute inset-0 z-[9999] pointer-events-none">
<div className="absolute inset-0 bg-neutral-500/5 dark:bg-neutral-400/10 transition-opacity duration-200" />
<div className="absolute inset-0 bg-neutral-50 bg-opacity-90 dark:bg-neutral-900 dark:bg-opacity-30 flex items-center justify-center">
<div className="bg-white/90 dark:bg-neutral-900/90 backdrop-blur-xl rounded-2xl p-12 mx-4 max-w-sm text-center border border-neutral-200/50 dark:border-neutral-700/50 shadow-2xl">
<DocumentPlusIcon className="w-8 h-8 mx-auto mb-2 text-black dark:text-white" />
<p className="text-neutral-500 dark:text-neutral-400 text-sm font-medium leading-relaxed">
Drop files here or paste from clipboard to add them to your
message
</p>
</div>
</div>
</div>
)}
</div>
);
}
import { useMemo, useEffect, useState } from "react";
import { isImageFile } from "@/utils/imageUtils";
export interface ImageData {
filename: string;
data: Uint8Array | number[] | string;
type?: string;
}
interface ImageThumbnailProps {
image: ImageData;
className?: string;
alt?: string;
onError?: () => void;
}
export function ImageThumbnail({
image,
className = "w-16 h-16 object-cover rounded-md select-none",
alt,
onError,
}: ImageThumbnailProps) {
const [imageLoadError, setImageLoadError] = useState(false);
const imageUrl = useMemo(() => {
if (!isImageFile(image.filename)) return "";
try {
// Determine MIME type from file extension
const extension = image.filename.toLowerCase().split(".").pop();
let mimeType = "application/octet-stream";
switch (extension) {
case "png":
mimeType = "image/png";
break;
case "jpg":
case "jpeg":
mimeType = "image/jpeg";
break;
case "gif":
mimeType = "image/gif";
break;
case "webp":
mimeType = "image/webp";
break;
}
// Convert to Uint8Array if needed
let bytes: Uint8Array;
if (image.data instanceof Uint8Array) {
bytes = image.data;
} else if (Array.isArray(image.data)) {
bytes = new Uint8Array(image.data);
} else if (typeof image.data === "string") {
// Convert base64 string to Uint8Array
const binaryString = atob(image.data);
bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
} else {
console.error(
"Invalid data format for:",
image.filename,
typeof image.data,
);
return "";
}
const blob = new Blob([bytes], { type: mimeType });
return URL.createObjectURL(blob);
} catch (error) {
console.error(
"Error converting file data to URL for",
image.filename,
":",
error,
);
return "";
}
}, [image]);
// Cleanup blob URL on unmount and reset error state when image changes
useEffect(() => {
setImageLoadError(false);
return () => {
if (imageUrl) {
URL.revokeObjectURL(imageUrl);
}
};
}, [imageUrl]);
if (!isImageFile(image.filename) || !imageUrl) {
return null;
}
if (imageLoadError) {
return (
<div
className={`flex items-center justify-center bg-neutral-50 dark:bg-neutral-600/50 rounded-md ${className}`}
>
<svg
className="w-4 h-4 text-neutral-400 dark:text-neutral-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
);
}
return (
<img
src={imageUrl}
alt={alt || image.filename}
className={className}
onError={() => {
setImageLoadError(true);
onError?.();
}}
/>
);
}
export default function Logo() {
return (
<div className="flex mb-8 justify-center select-none">
<div className="relative select-none">
<svg
width="60"
height="60"
viewBox="0 0 3400 3400"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="select-none"
>
<circle cx="1700" cy="1700" r="1700" fill="white" />
<path
d="M1070.57 220.832C1042.94 225.295 1009.78 239.748 986.406 257.6C915.632 311.371 860.798 425.502 837.632 567.687C828.918 621.458 822.967 696.057 822.967 753.016C822.967 820.177 830.831 906.04 842.095 965.337C844.646 978.514 845.921 990.203 844.858 991.053C844.008 991.904 833.594 1000.4 821.905 1009.76C781.948 1041.64 736.254 1090.73 704.799 1135.58C644.439 1221.23 605.333 1318.57 588.968 1423.98C582.592 1465.64 580.892 1549.8 585.992 1591.46C597.257 1687.53 626.161 1768.71 675.682 1843.1L691.834 1867.12L687.158 1874.98C654.003 1930.66 625.736 2011.21 612.559 2088.58C602.145 2149.79 600.87 2166.15 600.87 2248.19C600.87 2330.86 601.932 2347.23 611.709 2404.4C623.398 2472.84 647.202 2545.31 673.769 2593.56C682.483 2609.28 703.736 2642.01 706.286 2643.71C707.137 2644.14 704.586 2652 700.548 2661.14C669.943 2728.09 643.802 2817.14 632.962 2892.17C625.311 2943.6 624.249 2960.18 624.249 3014.37C624.249 3083.45 628.074 3117.03 642.526 3172.07L644.652 3180.15H735.616H826.793L820.842 3168.88C784.074 3100.87 780.673 2974.63 812.341 2848.6C826.793 2790.36 843.158 2747.64 873.763 2688.77L892.041 2653.07V2631.17C892.041 2610.77 891.616 2608.43 885.027 2595.04C879.926 2584.84 873.125 2576.13 861.011 2564.23C840.395 2544.25 825.518 2523.21 813.616 2497.28C761.333 2383.79 751.131 2215.25 787.899 2071.57C803.202 2011.64 828.493 1958.29 855.06 1929.18C873.125 1909.2 882.477 1886.88 882.477 1863.72C882.477 1839.7 873.975 1819.93 854.847 1799.32C800.014 1740.66 766.221 1669.25 754.107 1586.15C736.891 1467.77 768.134 1338.76 839.12 1236.53C908.618 1136.21 1006.17 1071.82 1115.2 1054.6C1139.64 1050.56 1185.34 1051.2 1210.84 1055.88C1238.68 1060.76 1256.11 1059.28 1273.96 1050.78C1296.07 1040.36 1307.12 1027.4 1320.08 997.642C1331.56 971.075 1340.49 956.623 1364.5 926.656C1393.41 890.738 1421.25 866.296 1465.88 836.754C1516.89 803.386 1574.91 779.158 1632.72 767.468C1653.76 763.218 1663.54 762.58 1702.86 762.58C1742.17 762.58 1751.95 763.218 1772.99 767.468C1857.79 784.683 1941.96 828.465 2009.12 890.525C2023.57 903.915 2058.21 946.847 2069.26 964.699C2073.51 971.713 2080.95 986.59 2085.63 997.642C2098.59 1027.4 2109.65 1040.36 2131.75 1050.78C2148.96 1059.06 2167.03 1060.76 2193.81 1056.3C2236.1 1049.08 2268.62 1049.71 2310.06 1058.21C2451.19 1086.69 2574.03 1202.95 2628.44 1358.74C2675.83 1495.4 2662.45 1638.43 2591.88 1747.67C2579.98 1766.16 2568.08 1781.04 2550.86 1799.32C2513.67 1839.06 2513.67 1888.37 2550.65 1929.18C2611.44 1995.7 2649.48 2159.35 2638 2303.66C2630.35 2398.88 2605.91 2484.1 2572.33 2532.35C2566.38 2540.85 2554.05 2555.3 2544.7 2564.23C2532.59 2576.13 2525.79 2584.84 2520.69 2595.04C2514.1 2608.43 2513.67 2610.77 2513.67 2631.17V2653.07L2531.95 2688.77C2562.55 2747.64 2578.92 2790.36 2593.37 2848.6C2624.61 2972.93 2621.85 3096.62 2586.15 3166.97C2583.17 3172.92 2580.62 3178.45 2580.62 3179.09C2580.62 3179.72 2621.21 3180.15 2670.95 3180.15H2761.06L2763.4 3171.01C2764.67 3166.12 2766.8 3158.68 2767.86 3154.43C2770.2 3145.08 2774.88 3117.45 2778.7 3090.88C2782.31 3064.11 2782.31 2965.49 2778.7 2935.73C2765.1 2827.77 2742.36 2742.12 2705.16 2661.14C2701.13 2652 2698.58 2644.14 2699.43 2643.71C2700.49 2643.08 2706.44 2634.57 2712.82 2625.01C2759.15 2554.87 2787.63 2466.67 2802.08 2350.21C2805.91 2318.11 2805.91 2180.18 2802.08 2149.36C2791.88 2069.87 2779.55 2015.89 2759.15 1961.27C2750.65 1938.53 2728.12 1890.5 2718.55 1874.98L2713.88 1867.12L2730.03 1843.1C2779.55 1768.71 2808.46 1687.53 2819.72 1591.46C2824.82 1549.8 2823.12 1465.64 2816.74 1423.98C2800.17 1318.36 2761.27 1221.44 2700.91 1135.58C2669.46 1090.73 2623.76 1041.64 2583.81 1009.76C2572.12 1000.4 2561.7 991.904 2560.85 991.053C2559.79 990.203 2561.07 978.514 2563.62 965.337C2589.33 831.228 2588.48 663.964 2561.49 533.256C2538.11 419.338 2495.61 328.799 2440.77 276.516C2396.99 234.859 2352.36 217.006 2298.8 220.407C2175.96 227.633 2076.92 368.968 2037.81 591.703C2031.43 627.621 2025.91 669.703 2025.91 681.18C2025.91 685.643 2025.06 689.256 2023.99 689.256C2022.93 689.256 2014.64 685.005 2005.72 679.692C1910.93 623.583 1805.51 593.616 1702.86 593.616C1600.2 593.616 1494.79 623.583 1400 679.692C1391.07 685.005 1382.78 689.256 1381.72 689.256C1380.66 689.256 1379.81 685.643 1379.81 681.18C1379.81 669.278 1374.07 625.921 1367.9 591.703C1332.41 391.709 1251.01 259.301 1142.83 225.933C1127.95 221.47 1085.66 218.494 1070.57 220.832ZM1106.7 393.834C1137.3 418.063 1171.31 487.349 1190.86 564.924C1194.48 578.951 1198.3 595.104 1199.36 601.054C1200.21 606.793 1202.55 619.758 1204.47 629.747C1212.75 674.804 1216.58 723.474 1217 782.771L1217.22 841.217L1202.55 862.896L1187.89 884.787H1153.67C1113.71 884.787 1073.97 889.888 1035.93 900.089C1022.32 903.49 1009.15 906.89 1006.6 907.528C1002.56 908.378 1001.92 907.103 999.583 889.675C987.043 795.098 987.681 690.319 1001.5 603.18C1016.8 506.052 1052.5 418.063 1087.36 392.134C1095.65 385.971 1097.14 386.183 1106.7 393.834ZM2318.57 392.347C2339.61 407.862 2362.77 449.093 2379.99 501.802C2414.63 607.218 2424.41 751.953 2406.13 889.675C2403.79 907.103 2403.15 908.378 2399.12 907.528C2396.57 906.89 2383.39 903.49 2369.79 900.089C2331.74 889.888 2292 884.787 2252.04 884.787H2217.82L2203.16 862.896L2188.5 841.217L2188.71 782.771C2189.13 700.308 2196.78 635.91 2215.06 564.286C2234.4 487.349 2268.62 418.063 2299.01 393.834C2308.58 386.183 2310.06 385.971 2318.57 392.347Z"
fill="black"
/>
<path
d="M1669.91 1462.03C1623.79 1466.49 1611.25 1468.19 1589.15 1472.66C1553.23 1480.09 1505.2 1496.67 1471.83 1513.04C1355.79 1569.78 1275.88 1664.36 1251.43 1773.82C1246.55 1795.49 1245.91 1802.72 1245.91 1839.28C1245.91 1875.41 1246.55 1883.27 1251.22 1903.89C1283.74 2046.92 1415.51 2152.55 1585.96 2171.89C1622.94 2175.93 1782.77 2175.93 1819.75 2171.89C1956.62 2156.38 2074.36 2082.2 2127.29 1978.06C2141.31 1950.22 2148.11 1932.15 2154.49 1903.89C2159.17 1883.27 2159.8 1875.41 2159.8 1839.28C2159.8 1802.72 2159.17 1795.49 2154.28 1773.82C2118.78 1614.84 1964.48 1489.66 1775.33 1465.85C1750.68 1462.88 1686.07 1460.33 1669.91 1462.03ZM1749.4 1577.65C1812.52 1584.45 1876.07 1606.98 1927.08 1640.98C1954.5 1659.26 1993.18 1697.52 2009.75 1722.59C2030.16 1753.62 2041.85 1785.29 2047.16 1823.76C2049.5 1841.4 2048.22 1854.79 2041.85 1883.27C2031.86 1925.78 2000.83 1970.2 1958.96 2001.23C1939.41 2015.47 1898.81 2036.08 1873.95 2044.16C1826.76 2059.25 1795.95 2062.01 1685.85 2061.16C1614.02 2060.52 1601.26 2059.89 1580.65 2056.06C1510.3 2042.88 1454.62 2014.83 1414.24 1972.11C1381.51 1937.68 1366.63 1906.22 1358.55 1855.43C1354.94 1831.84 1361.74 1792.73 1375.55 1759.79C1392.34 1719.62 1435.7 1669.67 1478.63 1640.98C1528.37 1607.83 1593.83 1584.24 1653.97 1577.86C1677.14 1575.31 1726.23 1575.31 1749.4 1577.65Z"
fill="black"
/>
<path
d="M1621.67 1732.8C1605.52 1741.51 1594.25 1763.61 1597.65 1779.98C1601.48 1797.62 1616.99 1815.47 1641.22 1830.14C1654.19 1838 1655.04 1839.06 1655.67 1846.93C1656.1 1851.6 1654.4 1864.99 1652.06 1876.89C1649.51 1888.58 1647.6 1900.91 1647.6 1904.31C1647.81 1913.45 1656.31 1928.33 1665.24 1935.55C1673.1 1941.93 1674.59 1942.14 1696.69 1942.78C1716.88 1943.42 1721.13 1942.99 1729.21 1939.17C1750.04 1928.97 1755.35 1910.26 1747.7 1874.34C1741.32 1844.38 1742.6 1839.7 1758.54 1830.56C1775.33 1820.79 1793.18 1803.57 1798.5 1791.88C1808.7 1769.56 1799.35 1744.27 1776.82 1732.58C1771.29 1729.82 1764.49 1728.55 1754.5 1728.55C1738.99 1728.55 1729 1732.16 1710.72 1743.85L1700.31 1750.44L1693.72 1746.4C1666.72 1730.46 1661.84 1728.55 1645.47 1728.76C1633.78 1728.76 1627.41 1729.82 1621.67 1732.8Z"
fill="black"
/>
<path
className="eye"
d="M1105.64 1486.04C1068.02 1497.95 1039.96 1525.58 1025.51 1564.89C1018.5 1583.6 1015.1 1613.14 1018.07 1629.08C1025.09 1667.12 1056.33 1701.77 1091.82 1711.33C1136.45 1723.02 1169.82 1715.37 1199.36 1686.04C1216.58 1669.25 1225.93 1654.58 1235.28 1630.78C1242.08 1613.99 1242.51 1611.01 1242.51 1587.21L1242.72 1561.71L1233.8 1543.43C1219.56 1514.52 1193.84 1493.06 1164.08 1485.19C1147.29 1480.94 1120.3 1481.16 1105.64 1486.04Z"
fill="black"
/>
<path
className="eye"
d="M2240.78 1485.41C2211.66 1493.27 2185.73 1514.95 2171.92 1543.43L2162.99 1561.71L2163.2 1587.21C2163.2 1611.01 2163.63 1613.99 2170.43 1630.78C2179.78 1654.58 2189.13 1669.25 2206.35 1686.04C2235.89 1715.37 2269.26 1723.02 2313.89 1711.33C2339.61 1704.53 2365.32 1682.85 2377.65 1657.56C2388.28 1636.09 2390.83 1620.58 2387.43 1596.14C2379.56 1540.24 2346.83 1499.65 2298.16 1485.41C2283.92 1481.16 2256.29 1481.16 2240.78 1485.41Z"
fill="black"
/>
</svg>
<style>{`
.eye {
transform-origin: center;
animation: blink 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
animation-delay: 2s;
animation-fill-mode: both;
}
@keyframes blink {
0%, 100% {
transform: scaleY(1);
}
15% {
transform: scaleY(0.4);
}
40% {
transform: scaleY(0.01);
}
65% {
transform: scaleY(0.4);
}
}
`}</style>
</div>
</div>
);
}
import type { Meta, StoryObj } from "@storybook/react-vite";
import Message from "./Message";
import { Message as MessageType, ToolCall, ToolFunction } from "@/gotypes";
const meta = {
title: "Components/Message",
component: Message,
parameters: {
layout: "padded",
},
tags: ["autodocs"],
argTypes: {
message: {
description: "The message object to display",
},
},
} satisfies Meta<typeof Message>;
export default meta;
type Story = StoryObj<typeof meta>;
// Helper function to create a message
const createMessage = (overrides: Partial<MessageType>): MessageType => {
const now = new Date();
return new MessageType({
role: "user",
content: "Hello, world!",
thinking: "",
stream: false,
created_at: now.toISOString(),
updated_at: now.toISOString(),
...overrides,
});
};
// User Messages
export const UserMessage: Story = {
args: {
message: createMessage({
role: "user",
content: "Can you help me understand how React hooks work?",
}),
isStreaming: false,
},
};
export const UserMessageWithMarkdown: Story = {
args: {
message: createMessage({
role: "user",
content:
"Here's my code:\n```javascript\nconst [count, setCount] = useState(0);\n```\nWhy isn't it working?",
}),
isStreaming: false,
},
};
// Assistant Messages
export const AssistantMessage: Story = {
args: {
message: createMessage({
role: "assistant",
content:
"I'd be happy to help you understand React hooks! React hooks are functions that let you use state and other React features in functional components.",
}),
isStreaming: false,
},
};
export const AssistantMessageWithCodeBlock: Story = {
args: {
message: createMessage({
role: "assistant",
content: `Here's an example of using the useState hook:
\`\`\`javascript
import React, { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
\`\`\`
This creates a simple counter component that tracks its state.`,
}),
isStreaming: false,
},
};
export const AssistantMessageWithThinking: Story = {
args: {
message: createMessage({
role: "assistant",
content:
"Based on my analysis, the issue with your code is that you need to import useState from React.",
thinking:
"The user is trying to use useState but hasn't imported it. This is a common mistake for beginners. I should provide a clear explanation and show the correct import statement.",
thinkingTimeStart: new Date(Date.now() - 3000),
thinkingTimeEnd: new Date(Date.now() - 1000),
}),
isStreaming: false,
},
};
export const AssistantMessageThinkingOnly: Story = {
args: {
message: createMessage({
role: "assistant",
content: "",
thinking:
"Processing the user's request and analyzing the code structure. This might take a moment while I consider the best approach...",
thinkingTimeStart: new Date(Date.now() - 2000),
}),
isStreaming: false,
},
};
// Tool Messages
export const ToolMessage: Story = {
args: {
message: createMessage({
role: "tool",
content: `{
"status": "success",
"data": {
"temperature": 72,
"humidity": 45,
"location": "San Francisco"
}
}`,
}),
isStreaming: false,
},
};
// Messages with Tool Calls
export const AssistantWithToolCall: Story = {
args: {
message: createMessage({
role: "assistant",
content: "I'll check the current weather for you.",
tool_calls: [
new ToolCall({
type: "function",
function: new ToolFunction({
name: "get_weather",
arguments: JSON.stringify({
location: "San Francisco",
units: "fahrenheit",
}),
result: {
temperature: 72,
humidity: 45,
conditions: "sunny",
},
}),
}),
],
}),
isStreaming: false,
},
};
export const AssistantWithMultipleToolCalls: Story = {
args: {
message: createMessage({
role: "assistant",
content: "Let me gather some information for you.",
tool_calls: [
new ToolCall({
type: "function",
function: new ToolFunction({
name: "search_web",
arguments: JSON.stringify({
query: "React hooks best practices",
limit: 5,
}),
}),
}),
new ToolCall({
type: "function",
function: new ToolFunction({
name: "read_documentation",
arguments: JSON.stringify({
url: "https://react.dev/reference/react/hooks",
section: "useState",
}),
result:
"useState is a React Hook that lets you add a state variable to your component.",
}),
}),
],
}),
isStreaming: false,
},
};
// Complex Message with Everything
export const ComplexAssistantMessage: Story = {
args: {
message: createMessage({
role: "assistant",
content: `## React Hooks Best Practices
Based on my research, here are the key best practices:
1. **Only call hooks at the top level** - Don't call hooks inside loops, conditions, or nested functions
2. **Only call hooks from React functions** - Either from React function components or custom hooks
3. **Use the ESLint plugin** - Install \`eslint-plugin-react-hooks\` to enforce these rules
### Example of correct usage:
\`\`\`javascript
function MyComponent() {
// ✅ Good - hooks at the top level
const [count, setCount] = useState(0);
const theme = useContext(ThemeContext);
if (count > 5) {
// ❌ Bad - hook inside condition
// const [error, setError] = useState(null);
}
return <div>{count}</div>;
}
\`\`\``,
thinking:
"The user needs a comprehensive guide on React hooks best practices. I should cover the rules of hooks, provide examples, and maybe include some tool calls to fetch the latest documentation.",
tool_calls: [
new ToolCall({
type: "function",
function: new ToolFunction({
name: "fetch_documentation",
arguments: JSON.stringify({
topic: "react-hooks-rules",
}),
result: "Successfully fetched React hooks documentation",
}),
}),
],
thinkingTimeStart: new Date(Date.now() - 5000),
thinkingTimeEnd: new Date(Date.now() - 3000),
}),
isStreaming: false,
},
};
// Assistant Message with Raw HTML
export const AssistantMessageWithHTML: Story = {
args: {
isStreaming: false,
message: createMessage({
role: "assistant",
content: `Here are some HTML examples and how they render:
## Basic HTML Elements
<div style="background-color: #f0f0f0; padding: 10px; border-radius: 5px;">
<p>This is a <strong>paragraph</strong> inside a <em>styled div</em>.</p>
</div>
<blockquote>
This is a blockquote with <code>inline code</code> and <a href="#">a link</a>.
</blockquote>
## Lists and Tables
<ul>
<li>First item with <b>bold text</b></li>
<li>Second item with <i>italic text</i></li>
<li>Third item with <mark>highlighted text</mark></li>
</ul>
<table>
<thead>
<tr>
<th>Header 1</th>
<th>Header 2</th>
</tr>
</thead>
<tbody>
<tr>
<td>Cell 1</td>
<td>Cell 2</td>
</tr>
<tr>
<td>Cell 3</td>
<td>Cell 4</td>
</tr>
</tbody>
</table>
## Forms and Inputs
<details>
<summary>Click to expand</summary>
<p>Hidden content here with <span style="color: red;">colored text</span>.</p>
</details>
<pre><code class="language-html">&lt;button&gt;This is HTML code&lt;/button&gt;</code></pre>
## Potentially Dangerous HTML
<script>console.log('This script should not execute');</script>
<iframe src="https://example.com">This iframe should not render</iframe>
<img src="x" onerror="alert('XSS')" />
<a href="javascript:alert('XSS')">Dangerous link</a>
## Special Characters
&lt;div&gt; &amp; &quot;quotes&quot; &apos;apostrophes&apos; &copy; &reg; &trade;
## Inline Styles
<p style="font-size: 20px; color: blue;">Large blue text</p>
<span style="background: yellow; padding: 2px 4px;">Highlighted span</span>
<div style="border: 2px solid green; margin: 10px 0; padding: 10px;">
Box with green border
</div>`,
}),
},
};
// Long Content Message
export const LongContentMessage: Story = {
args: {
message: createMessage({
role: "assistant",
content: `# Comprehensive Guide to React Hooks
React Hooks revolutionized how we write React components by allowing us to use state and other React features without writing a class. Let's dive deep into understanding hooks.
## What are React Hooks?
Hooks are functions that let you "hook into" React state and lifecycle features from function components. They don't work inside classes — they let you use React without classes.
## Core Hooks
### useState
The State Hook lets you add React state to function components.
\`\`\`javascript
const [state, setState] = useState(initialState);
\`\`\`
### useEffect
The Effect Hook lets you perform side effects in function components:
\`\`\`javascript
useEffect(() => {
// Side effect logic here
return () => {
// Cleanup logic here
};
}, [dependencies]);
\`\`\`
### useContext
Accepts a context object and returns the current context value:
\`\`\`javascript
const value = useContext(MyContext);
\`\`\`
## Additional Hooks
- **useReducer**: An alternative to useState for complex state logic
- **useCallback**: Returns a memoized callback
- **useMemo**: Returns a memoized value
- **useRef**: Returns a mutable ref object
- **useImperativeHandle**: Customizes the instance value exposed to parent components
- **useLayoutEffect**: Similar to useEffect, but fires synchronously
- **useDebugValue**: Displays a label for custom hooks in React DevTools
## Custom Hooks
You can create your own hooks to reuse stateful logic between components:
\`\`\`javascript
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => {
setCount(c => c + 1);
}, []);
const decrement = useCallback(() => {
setCount(c => c - 1);
}, []);
return { count, increment, decrement };
}
\`\`\`
## Best Practices and Rules
1. **Only Call Hooks at the Top Level**
2. **Only Call Hooks from React Functions**
3. **Use the ESLint Plugin**
4. **Keep Effects Clean**
5. **Optimize with useMemo and useCallback**
This is just the beginning of what you can do with React Hooks!`,
}),
isStreaming: false,
},
};
import { Message as MessageType, ToolCall, File } from "@/gotypes";
import Thinking from "./Thinking";
import StreamingMarkdownContent from "./StreamingMarkdownContent";
import { ImageThumbnail } from "./ImageThumbnail";
import { isImageFile } from "@/utils/imageUtils";
import CopyButton from "./CopyButton";
import React, { useState, useMemo, useRef } from "react";
const Message = React.memo(
({
message,
onEditMessage,
messageIndex,
isStreaming,
isFaded,
browserToolResult,
lastToolQuery,
}: {
message: MessageType;
onEditMessage?: (content: string, index: number) => void;
messageIndex?: number;
isStreaming: boolean;
isFaded?: boolean;
// TODO(drifkin): this type isn't right
browserToolResult?: BrowserToolResult;
lastToolQuery?: string;
}) => {
if (message.role === "user") {
return (
<UserMessage
message={message}
onEditMessage={onEditMessage}
messageIndex={messageIndex}
isFaded={isFaded}
/>
);
} else {
return (
<OtherRoleMessage
message={message}
isStreaming={isStreaming}
isFaded={isFaded}
browserToolResult={browserToolResult}
lastToolQuery={lastToolQuery}
/>
);
}
},
(prevProps, nextProps) => {
return (
prevProps.message === nextProps.message &&
prevProps.onEditMessage === nextProps.onEditMessage &&
prevProps.messageIndex === nextProps.messageIndex &&
prevProps.isStreaming === nextProps.isStreaming &&
prevProps.isFaded === nextProps.isFaded &&
prevProps.browserToolResult === nextProps.browserToolResult
);
},
);
export default Message;
// TODO(drifkin): fill in more (or generate from go types)
type BrowserToolResult = {
page_stack: string[];
};
type BrowserToolContent = {
cursor: number;
title: string;
url: string;
startingLine: number;
totalLines: number;
lines: string[];
};
// Example:
/*
[0] Devon Rifkin(search_results_Devon Rifkin)
**viewing lines [0 - 134] of 167**
L0:
L1:
L2: URL:
L3: # Search Results
*/
function processBrowserToolContent(content: string): BrowserToolContent {
const lines = content.split("\n");
const firstLine = lines[0];
// For a first line like the following:
// [0] Page Title(search_results_Query)
// we want to extract:
// - cursor: 0
// - title: Page Title
// - url: search_results_Query
// use a regex to extract the cursor, title and URL, all in one shot. It's okay if the page title has parens in it, the very last parens should be the URL
const firstLineMatch = firstLine.match(/^\[(\d+)\]\s+(.+)\(([^)]+)\)$/);
const cursor = firstLineMatch ? parseInt(firstLineMatch[1], 10) : 0;
const title = firstLineMatch ? firstLineMatch[2].trim() : "";
const url = firstLineMatch ? firstLineMatch[3] : "";
// Parse the viewing lines info from the second line
// Example: **viewing lines [0 - 134] of 167**
const viewingLineMatch = lines[1]?.match(
/\*\*viewing lines \[(\d+) - (\d+)\] of (\d+)\*\*/,
);
const startingLine = viewingLineMatch ? parseInt(viewingLineMatch[1], 10) : 0;
let totalLines = viewingLineMatch ? parseInt(viewingLineMatch[3], 10) : 0;
// TEMP(drifkin): waiting for a fix from parth, for now making it so we make
// sure the total lines is at least as much as the ending line number + 1
const endingLine = viewingLineMatch ? parseInt(viewingLineMatch[2], 10) : 0;
totalLines = Math.max(totalLines, endingLine + 1);
// Extract the actual content lines (skip first 2 lines and empty line 3)
const contentLines = lines.slice(3).filter((line) => line.startsWith("L"));
// remove the L<number>: prefix with a regex
const contentLinesWithoutPrefix = contentLines.map((line) =>
line.replace(/^L(\d+):\s*/, ""),
);
return {
cursor,
title,
url,
startingLine,
totalLines,
lines: contentLinesWithoutPrefix,
};
}
function BrowserToolResult({
content,
}: {
toolResult: BrowserToolResult;
content: string;
}) {
const [isCollapsed, setIsCollapsed] = React.useState(true);
const processedContent = useMemo(
() => processBrowserToolContent(content),
[content],
);
let urlToUse: string | null = null;
if (processedContent.url.startsWith("http")) {
urlToUse = processedContent.url;
}
const isSearchResults =
/^search_results_/i.test(processedContent.url) ||
/_search$/i.test(processedContent.url);
return (
<div
className={`flex flex-col w-full ${!isCollapsed ? "text-neutral-800 dark:text-neutral-200" : "text-neutral-600 dark:text-neutral-400"}
hover:text-neutral-800
dark:hover:text-neutral-200 transition-colors`}
>
<div
className="flex cursor-pointer group/browser self-start relative"
onClick={() => setIsCollapsed(!isCollapsed)}
>
{/* Browser icon */}
<svg
className={`w-10 absolute -left-0.5 top-1 transition-opacity ${
isCollapsed ? "opacity-100" : "opacity-0"
} group-hover/browser:opacity-0 fill-current will-change-opacity`}
xmlns="http://www.w3.org/2000/svg"
>
<path d="M17.9297 8.96484C17.9297 13.9131 13.9131 17.9297 8.96484 17.9297C4.0166 17.9297 0 13.9131 0 8.96484C0 4.0166 4.0166 0 8.96484 0C13.9131 0 17.9297 4.0166 17.9297 8.96484ZM8.52608 0.544075C7.9195 0.648681 7.34943 0.980368 6.84016 1.49718C5.65243 1.83219 4.58058 2.44328 3.70023 3.25921C3.62549 3.21723 3.56076 3.16926 3.49805 3.12012C3.24316 2.91797 2.90918 2.8916 2.68066 3.11133C2.46094 3.33105 2.44336 3.68262 2.68945 3.91113C2.76044 3.97523 2.83584 4.03812 2.91591 4.09957C1.95192 5.28662 1.33642 6.76661 1.2196 8.38477H1.05469C0.738281 8.38477 0.474609 8.64844 0.474609 8.96484C0.474609 9.28125 0.738281 9.53613 1.05469 9.53613H1.21922C1.33511 11.1701 1.95962 12.6636 2.93835 13.8571C2.84984 13.9239 2.76702 13.9925 2.68945 14.0625C2.44336 14.2822 2.46094 14.6426 2.68066 14.8623C2.90918 15.082 3.24316 15.0557 3.49805 14.8535C3.56925 14.7972 3.64306 14.7424 3.72762 14.6944C4.60071 15.4974 5.6608 16.0989 6.83417 16.431C7.32996 16.9376 7.88339 17.2682 8.47128 17.3845C8.56963 17.5572 8.75351 17.6748 8.96484 17.6748C9.1767 17.6748 9.36492 17.5566 9.46571 17.3832C10.0509 17.2655 10.6018 16.9355 11.0955 16.431C12.2689 16.0989 13.329 15.4974 14.2021 14.6944C14.2866 14.7424 14.3604 14.7972 14.4316 14.8535C14.6865 15.0557 15.0205 15.082 15.249 14.8623C15.4688 14.6426 15.4863 14.2822 15.2402 14.0625C15.1627 13.9925 15.0798 13.9239 14.9913 13.8571C15.9701 12.6636 16.5946 11.1701 16.7105 9.53613H17.0508C17.3672 9.53613 17.6309 9.28125 17.6309 8.96484C17.6309 8.64844 17.3672 8.38477 17.0508 8.38477H16.7101C16.5933 6.76661 15.9778 5.28661 15.0138 4.09957C15.0939 4.03812 15.1692 3.97523 15.2402 3.91113C15.4863 3.68262 15.4688 3.33105 15.249 3.11133C15.0205 2.8916 14.6865 2.91797 14.4316 3.12012C14.3689 3.16926 14.3042 3.21723 14.2295 3.25921C13.3491 2.44328 12.2773 1.83219 11.0895 1.49718C10.582 0.982111 10.014 0.650921 9.40974 0.545145C9.30275 0.416705 9.14207 0.333984 8.96484 0.333984C8.78813 0.333984 8.63061 0.416228 8.52608 0.544075Z" />
<path d="M8.39345 16.2322V17.0946C8.39345 17.1996 8.42151 17.2988 8.47104 17.3841C7.17953 17.1251 6.0523 15.8435 5.33988 13.9209C5.68987 13.8012 6.07289 13.7058 6.48098 13.6307C6.9693 14.9474 7.64728 15.9065 8.39345 16.2322ZM12.5896 13.9209C11.8787 15.8395 10.7546 17.1198 9.46632 17.3819C9.5163 17.2971 9.54482 17.1987 9.54482 17.0946V16.229C10.2875 15.899 10.9621 14.9423 11.4485 13.6307C11.8566 13.7058 12.2396 13.8012 12.5896 13.9209ZM6.14535 12.5477C5.74273 12.6298 5.35968 12.7296 5.00217 12.8459C4.73645 11.8577 4.57374 10.7411 4.54053 9.53605H5.71744C5.75386 10.6089 5.90649 11.6345 6.14535 12.5477ZM12.9273 12.8459C12.5698 12.7296 12.1868 12.6298 11.7841 12.5477C12.023 11.6345 12.1756 10.6089 12.212 9.53605H13.389C13.3557 10.7411 13.193 11.8577 12.9273 12.8459ZM6.13265 5.42334C5.9038 6.32028 5.75639 7.32624 5.71828 8.38469H4.54152C4.57689 7.19848 4.73601 6.09939 4.9947 5.12525C5.35068 5.24129 5.73201 5.341 6.13265 5.42334ZM13.388 8.38469H12.2112C12.1731 7.32624 12.0257 6.32028 11.7968 5.42334C12.1975 5.341 12.5788 5.24129 12.9348 5.12525C13.1935 6.09939 13.3526 7.19848 13.388 8.38469ZM8.39345 0.913986V1.69612C7.63864 2.02298 6.95359 2.99375 6.46426 4.3367C6.05787 4.26121 5.67698 4.16517 5.32878 4.04549C6.0504 2.07603 7.2039 0.775083 8.52864 0.541016C8.44416 0.642235 8.39345 0.772865 8.39345 0.913986ZM12.6007 4.04549C12.2525 4.16517 11.8716 4.26121 11.4652 4.3367C10.9778 2.99892 10.2961 2.03048 9.54482 1.69935V0.913986C9.54482 0.77311 9.49255 0.642688 9.40642 0.541528C10.7288 0.778686 11.8801 2.07875 12.6007 4.04549Z" />
<path d="M8.96484 17.6748C9.28125 17.6748 9.54492 17.4111 9.54492 17.0947V0.914062C9.54492 0.597656 9.28125 0.333984 8.96484 0.333984C8.64844 0.333984 8.39355 0.597656 8.39355 0.914062V17.0947C8.39355 17.4111 8.64844 17.6748 8.96484 17.6748ZM3.49805 14.8535C4.67578 13.9219 6.56543 13.4209 8.96484 13.4209C11.3643 13.4209 13.2539 13.9219 14.4316 14.8535C14.6865 15.0557 15.0205 15.082 15.249 14.8623C15.4688 14.6426 15.4863 14.2822 15.2402 14.0625C14.0625 12.999 11.6719 12.2695 8.96484 12.2695C6.25781 12.2695 3.86719 12.999 2.68945 14.0625C2.44336 14.2822 2.46094 14.6426 2.68066 14.8623C2.90918 15.082 3.24316 15.0557 3.49805 14.8535ZM1.05469 9.53613H17.0508C17.3672 9.53613 17.6309 9.28125 17.6309 8.96484C17.6309 8.64844 17.3672 8.38477 17.0508 8.38477H1.05469C0.738281 8.38477 0.474609 8.64844 0.474609 8.96484C0.474609 9.28125 0.738281 9.53613 1.05469 9.53613ZM8.96484 5.7041C11.6719 5.7041 14.0625 4.97461 15.2402 3.91113C15.4863 3.68262 15.4688 3.33105 15.249 3.11133C15.0205 2.8916 14.6865 2.91797 14.4316 3.12012C13.2539 4.04297 11.3643 4.55273 8.96484 4.55273C6.56543 4.55273 4.67578 4.04297 3.49805 3.12012C3.24316 2.91797 2.90918 2.8916 2.68066 3.11133C2.46094 3.33105 2.44336 3.68262 2.68945 3.91113C3.86719 4.97461 6.25781 5.7041 8.96484 5.7041Z" />
</svg>
{/* Arrow */}
<svg
className={`h-4 w-4 absolute top-1.5 transition-all ${
isCollapsed
? "-rotate-90 opacity-0 group-hover/browser:opacity-100"
: "rotate-0 opacity-100"
} will-change-[opacity,transform]`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<div className="ml-6">
{isSearchResults ? (
<span>
{(() => {
const term = /^search_results_/i.test(processedContent.url)
? processedContent.url.replace(/^search_results_/i, "")
: processedContent.url.replace(/_search$/i, "");
return (
<>
Search results for <InlineSearchTerm term={term} />
</>
);
})()}
</span>
) : (
<InlineSearchTerm term={processedContent.title} />
)}
{urlToUse != null && (
<span className="text-neutral-500 text-sm ml-2 break-all">
({urlToUse})
</span>
)}
<span className="text-neutral-500 text-sm ml-2">
(lines {processedContent.startingLine}-
{processedContent.startingLine + processedContent.lines.length - 1}{" "}
of {processedContent.totalLines})
</span>
</div>
</div>
<div
className={`text-xs text-neutral-500 dark:text-neutral-500 rounded-md overflow-y-auto
transition-[max-height,opacity] duration-300 ease-in-out ml-6 mt-2`}
style={{
maxHeight: isCollapsed ? "0px" : "20rem",
opacity: isCollapsed ? 0 : 1,
}}
>
<div className="transition-transform duration-300 opacity-75">
<div className="overflow-x-auto">
{processedContent.lines.map((line, index) => {
const lineNumber = processedContent.startingLine + index;
return (
<div
key={index}
className="flex whitespace-nowrap text-xs h-[2em] font-mono"
>
<div className="w-10 text-right pr-2 text-neutral-500 flex-shrink-0 border-r border-neutral-200 dark:border-neutral-700">
{lineNumber}
</div>
<div className="pl-2 pr-4">{line}</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
function ToolRoleContent({
message,
browserToolResult,
lastToolQuery,
}: {
message: MessageType;
browserToolResult?: BrowserToolResult;
lastToolQuery?: string;
}) {
const content = message.content;
const rawToolResult = (message as any).tool_result;
const toolName = (message as any).tool_name || (message as any).toolName;
const [isCollapsed, setIsCollapsed] = useState(true);
if (browserToolResult && typeof browserToolResult === "object") {
return (
<BrowserToolResult toolResult={browserToolResult} content={content} />
);
}
return (
// collapsable tool result with raw json
<div className="space-y-2">
{content && !rawToolResult && (
<pre className="text-xs whitespace-pre-wrap overflow-x-auto bg-neutral-100 dark:bg-neutral-800 text-neutral-800 dark:text-neutral-200 p-2 rounded-md max-h-40">
<code>{content}</code>
</pre>
)}
{rawToolResult && (
<div className="flex flex-col w-full text-neutral-600 dark:text-neutral-400 relative select-text hover:text-neutral-800 dark:hover:text-neutral-200 transition-colors">
<div
className="flex cursor-pointer group/browser self-start relative"
onClick={() => setIsCollapsed(!isCollapsed)}
>
{/* Globe icon */}
<svg
className="w-10 absolute -left-0.5 top-1 fill-current transition-opacity group-hover/browser:opacity-0"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M17.9297 8.96484C17.9297 13.9131 13.9131 17.9297 8.96484 17.9297C4.0166 17.9297 0 13.9131 0 8.96484C0 4.0166 4.0166 0 8.96484 0C13.9131 0 17.9297 4.0166 17.9297 8.96484ZM8.52608 0.544075C7.9195 0.648681 7.34943 0.980368 6.84016 1.49718C5.65243 1.83219 4.58058 2.44328 3.70023 3.25921C3.62549 3.21723 3.56076 3.16926 3.49805 3.12012C3.24316 2.91797 2.90918 2.8916 2.68066 3.11133C2.46094 3.33105 2.44336 3.68262 2.68945 3.91113C2.76044 3.97523 2.83584 4.03812 2.91591 4.09957C1.95192 5.28662 1.33642 6.76661 1.2196 8.38477H1.05469C0.738281 8.38477 0.474609 8.64844 0.474609 8.96484C0.474609 9.28125 0.738281 9.53613 1.05469 9.53613H1.21922C1.33511 11.1701 1.95962 12.6636 2.93835 13.8571C2.84984 13.9239 2.76702 13.9925 2.68945 14.0625C2.44336 14.2822 2.46094 14.6426 2.68066 14.8623C2.90918 15.082 3.24316 15.0557 3.49805 14.8535C3.56925 14.7972 3.64306 14.7424 3.72762 14.6944C4.60071 15.4974 5.6608 16.0989 6.83417 16.431C7.32996 16.9376 7.88339 17.2682 8.47128 17.3845C8.56963 17.5572 8.75351 17.6748 8.96484 17.6748C9.1767 17.6748 9.36492 17.5566 9.46571 17.3832C10.0509 17.2655 10.6018 16.9355 11.0955 16.431C12.2689 16.0989 13.329 15.4974 14.2021 14.6944C14.2866 14.7424 14.3604 14.7972 14.4316 14.8535C14.6865 15.0557 15.0205 15.082 15.249 14.8623C15.4688 14.6426 15.4863 14.2822 15.2402 14.0625C15.1627 13.9925 15.0798 13.9239 14.9913 13.8571C15.9701 12.6636 16.5946 11.1701 16.7105 9.53613H17.0508C17.3672 9.53613 17.6309 9.28125 17.6309 8.96484C17.6309 8.64844 17.3672 8.38477 17.0508 8.38477H16.7101C16.5933 6.76661 15.9778 5.28661 15.0138 4.09957C15.0939 4.03812 15.1692 3.97523 15.2402 3.91113C15.4863 3.68262 15.4688 3.33105 15.249 3.11133C15.0205 2.8916 14.6865 2.91797 14.4316 3.12012C14.3689 3.16926 14.3042 3.21723 14.2295 3.25921C13.3491 2.44328 12.2773 1.83219 11.0895 1.49718C10.582 0.982111 10.014 0.650921 9.40974 0.545145C9.30275 0.416705 9.14207 0.333984 8.96484 0.333984C8.78813 0.333984 8.63061 0.416228 8.52608 0.544075Z" />
<path d="M8.39345 16.2322V17.0946C8.39345 17.1996 8.42151 17.2988 8.47104 17.3841C7.17953 17.1251 6.0523 15.8435 5.33988 13.9209C5.68987 13.8012 6.07289 13.7058 6.48098 13.6307C6.9693 14.9474 7.64728 15.9065 8.39345 16.2322ZM12.5896 13.9209C11.8787 15.8395 10.7546 17.1198 9.46632 17.3819C9.5163 17.2971 9.54482 17.1987 9.54482 17.0946V16.229C10.2875 15.899 10.9621 14.9423 11.4485 13.6307C11.8566 13.7058 12.2396 13.8012 12.5896 13.9209ZM6.14535 12.5477C5.74273 12.6298 5.35968 12.7296 5.00217 12.8459C4.73645 11.8577 4.57374 10.7411 4.54053 9.53605H5.71744C5.75386 10.6089 5.90649 11.6345 6.14535 12.5477ZM12.9273 12.8459C12.5698 12.7296 12.1868 12.6298 11.7841 12.5477C12.023 11.6345 12.1756 10.6089 12.212 9.53605H13.389C13.3557 10.7411 13.193 11.8577 12.9273 12.8459ZM6.13265 5.42334C5.9038 6.32028 5.75639 7.32624 5.71828 8.38469H4.54152C4.57689 7.19848 4.73601 6.09939 4.9947 5.12525C5.35068 5.24129 5.73201 5.341 6.13265 5.42334ZM13.388 8.38469H12.2112C12.1731 7.32624 12.0257 6.32028 11.7968 5.42334C12.1975 5.341 12.5788 5.24129 12.9348 5.12525C13.1935 6.09939 13.3526 7.19848 13.388 8.38469ZM8.39345 0.913986V1.69612C7.63864 2.02298 6.95359 2.99375 6.46426 4.3367C6.05787 4.26121 5.67698 4.16517 5.32878 4.04549C6.0504 2.07603 7.2039 0.775083 8.52864 0.541016C8.44416 0.642235 8.39345 0.772865 8.39345 0.913986ZM12.6007 4.04549C12.2525 4.16517 11.8716 4.26121 11.4652 4.3367C10.9778 2.99892 10.2961 2.03048 9.54482 1.69935V0.913986C9.54482 0.77311 9.49255 0.642688 9.40642 0.541528C10.7288 0.778686 11.8801 2.07875 12.6007 4.04549Z" />
<path d="M8.96484 17.6748C9.28125 17.6748 9.54492 17.4111 9.54492 17.0947V0.914062C9.54492 0.597656 9.28125 0.333984 8.96484 0.333984C8.64844 0.333984 8.39355 0.597656 8.39355 0.914062V17.0947C8.39355 17.4111 8.64844 17.6748 8.96484 17.6748ZM3.49805 14.8535C4.67578 13.9219 6.56543 13.4209 8.96484 13.4209C11.3643 13.4209 13.2539 13.9219 14.4316 14.8535C14.6865 15.0557 15.0205 15.082 15.249 14.8623C15.4688 14.6426 15.4863 14.2822 15.2402 14.0625C14.0625 12.999 11.6719 12.2695 8.96484 12.2695C6.25781 12.2695 3.86719 12.999 2.68945 14.0625C2.44336 14.2822 2.46094 14.6426 2.68066 14.8623C2.90918 15.082 3.24316 15.0557 3.49805 14.8535ZM1.05469 9.53613H17.0508C17.3672 9.53613 17.6309 9.28125 17.6309 8.96484C17.6309 8.64844 17.3672 8.38477 17.0508 8.38477H1.05469C0.738281 8.38477 0.474609 8.64844 0.474609 8.96484C0.474609 9.28125 0.738281 9.53613 1.05469 9.53613ZM8.96484 5.7041C11.6719 5.7041 14.0625 4.97461 15.2402 3.91113C15.4863 3.68262 15.4688 3.33105 15.249 3.11133C15.0205 2.8916 14.6865 2.91797 14.4316 3.12012C13.2539 4.04297 11.3643 4.55273 8.96484 4.55273C6.56543 4.55273 4.67578 4.04297 3.49805 3.12012C3.24316 2.91797 2.90918 2.8916 2.68066 3.11133C2.46094 3.33105 2.44336 3.68262 2.68945 3.91113C3.86719 4.97461 6.25781 5.7041 8.96484 5.7041Z" />
</svg>
{/* Arrow */}
<svg
className={`h-4 w-4 absolute top-1.5 transition-all opacity-0 group-hover/browser:opacity-100 ${
isCollapsed ? "-rotate-90" : "rotate-0"
} will-change-[opacity,transform]`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<div
className={`${toolName === "web_search" || toolName === "web_fetch" ? "ml-6" : "ml-6"}`}
>
<span>
{toolName === "web_search"
? (() => {
const q =
(lastToolQuery && lastToolQuery.trim()) ||
(rawToolResult &&
typeof rawToolResult === "object" &&
typeof (rawToolResult as any).query === "string"
? (rawToolResult as any).query.trim()
: "");
return q ? (
<>
Search results for <InlineSearchTerm term={q} />
</>
) : (
"Web search results"
);
})()
: toolName === "web_fetch"
? (() => {
const u =
(lastToolQuery && lastToolQuery.trim()) ||
(rawToolResult &&
typeof rawToolResult === "object" &&
typeof (rawToolResult as any).url === "string"
? (rawToolResult as any).url
: "");
return u ? (
<>
Fetch results for{" "}
<span className="break-all">{u}</span>
</>
) : (
"Web fetch results"
);
})()
: "Raw tool result"}
</span>
</div>
</div>
<div
className="text-xs text-neutral-500 dark:text-neutral-500 rounded-md overflow-y-auto transition-[max-height,opacity] duration-300 ease-in-out ml-6 mt-2"
style={{
maxHeight: isCollapsed ? "0px" : "20rem",
opacity: isCollapsed ? 0 : 1,
}}
>
<pre
id="raw-json-tool-result"
className="text-xs overflow-x-auto bg-neutral-50 dark:bg-neutral-900 text-neutral-800 dark:text-neutral-200 p-2 rounded-md border border-neutral-200 dark:border-neutral-700"
>
<code>
{typeof rawToolResult === "string"
? rawToolResult
: JSON.stringify(rawToolResult, null, 2)}
</code>
</pre>
</div>
</div>
)}
</div>
);
}
function InlineSearchTerm({ term }: { term: string }) {
return (
// <span className="font-bold before:content-['\201C'] after:content-['\201D']">
<span className="font-medium">{term}</span>
);
}
function cursorToPageText(
cursor: number,
browserToolResult: BrowserToolResult | undefined,
): string {
if (browserToolResult) {
let page = browserToolResult.page_stack[cursor];
if (page) {
if (page.startsWith("search_results_")) {
const searchTerm = page.replace(/^search_results_/, "");
page = `Search results for "${searchTerm}"`;
}
return page;
}
return page || "Unknown page";
}
if (cursor === undefined) {
console.warn("cursor is undefined");
return "Page";
}
return `Page #${cursor}`;
}
function cursorToPage(
cursor: number,
browserToolResult: BrowserToolResult | undefined,
) {
const pageText = cursorToPageText(cursor, browserToolResult);
return (
<span className="font-medium text-sm text-neutral-500 border border-neutral-200 dark:border-neutral-700 rounded-md px-1 py-0.5">
{pageText}
</span>
);
}
// TODO(drifkin): pull out into another file
function BrowserToolCallDisplay({
toolCall,
browserToolResult,
}: {
toolCall: ToolCall;
browserToolResult?: BrowserToolResult;
}) {
const args = JSON.parse(toolCall.function.arguments);
if (toolCall.function.name === "browser.search") {
const query = args.query;
return (
<div className="text-neutral-600 dark:text-neutral-400 relative mb-3 select-text">
<svg
className="fill-current h-4 absolute top-1"
viewBox="0 0 18 18"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 7.01367C0 10.8809 3.14648 14.0273 7.01367 14.0273C8.54297 14.0273 9.94043 13.5352 11.0918 12.709L15.416 17.042C15.6182 17.2441 15.8818 17.3408 16.1631 17.3408C16.7607 17.3408 17.1738 16.8926 17.1738 16.3037C17.1738 16.0225 17.0684 15.7676 16.8838 15.583L12.5859 11.2588C13.4912 10.0811 14.0273 8.61328 14.0273 7.01367C14.0273 3.14648 10.8809 0 7.01367 0C3.14648 0 0 3.14648 0 7.01367ZM1.50293 7.01367C1.50293 3.97266 3.97266 1.50293 7.01367 1.50293C10.0547 1.50293 12.5244 3.97266 12.5244 7.01367C12.5244 10.0547 10.0547 12.5244 7.01367 12.5244C3.97266 12.5244 1.50293 10.0547 1.50293 7.01367Z" />
</svg>
<div className="ml-6">
Searching for <InlineSearchTerm term={query} />
&#8230;
</div>
</div>
);
} else if (toolCall.function.name === "browser.open") {
const cursor = args.cursor;
const id = args.id;
const idAllNumeric = !isNaN(Number(id));
if (idAllNumeric) {
return (
<div className="text-neutral-600 dark:text-neutral-400 relative mb-3 select-text">
<svg
className="fill-current h-4 absolute top-1"
viewBox="0 0 18 18"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 7.01367C0 10.8809 3.14648 14.0273 7.01367 14.0273C8.54297 14.0273 9.94043 13.5352 11.0918 12.709L15.416 17.042C15.6182 17.2441 15.8818 17.3408 16.1631 17.3408C16.7607 17.3408 17.1738 16.8926 17.1738 16.3037C17.1738 16.0225 17.0684 15.7676 16.8838 15.583L12.5859 11.2588C13.4912 10.0811 14.0273 8.61328 14.0273 7.01367C14.0273 3.14648 10.8809 0 7.01367 0C3.14648 0 0 3.14648 0 7.01367ZM1.50293 7.01367C1.50293 3.97266 3.97266 1.50293 7.01367 1.50293C10.0547 1.50293 12.5244 3.97266 12.5244 7.01367C12.5244 10.0547 10.0547 12.5244 7.01367 12.5244C3.97266 12.5244 1.50293 10.0547 1.50293 7.01367Z" />
</svg>
<div className="ml-6">
Opening link #{id} from {cursorToPage(cursor, browserToolResult)}
</div>
</div>
);
} else {
const loc = args.loc;
return (
<div className="text-neutral-600 dark:text-neutral-400 relative mb-3 select-text">
<svg
className="fill-current h-4 absolute top-1"
viewBox="0 0 18 18"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 7.01367C0 10.8809 3.14648 14.0273 7.01367 14.0273C8.54297 14.0273 9.94043 13.5352 11.0918 12.709L15.416 17.042C15.6182 17.2441 15.8818 17.3408 16.1631 17.3408C16.7607 17.3408 17.1738 16.8926 17.1738 16.3037C17.1738 16.0225 17.0684 15.7676 16.8838 15.583L12.5859 11.2588C13.4912 10.0811 14.0273 8.61328 14.0273 7.01367C14.0273 3.14648 10.8809 0 7.01367 0C3.14648 0 0 3.14648 0 7.01367ZM1.50293 7.01367C1.50293 3.97266 3.97266 1.50293 7.01367 1.50293C10.0547 1.50293 12.5244 3.97266 12.5244 7.01367C12.5244 10.0547 10.0547 12.5244 7.01367 12.5244C3.97266 12.5244 1.50293 10.0547 1.50293 7.01367Z" />
</svg>
<div className="ml-6">
{loc
? `Scrolling to line ${loc} on ${cursorToPageText(
cursor,
browserToolResult,
)}`
: `Scrolling`}
</div>
</div>
);
}
} else if (toolCall.function.name === "browser.find") {
const cursor = args.cursor;
const pattern = args.pattern;
return (
<div className="text-neutral-600 dark:text-neutral-400 relative mb-3 select-text">
<svg
className="fill-current h-4 absolute top-1"
viewBox="0 0 18 18"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 7.01367C0 10.8809 3.14648 14.0273 7.01367 14.0273C8.54297 14.0273 9.94043 13.5352 11.0918 12.709L15.416 17.042C15.6182 17.2441 15.8818 17.3408 16.1631 17.3408C16.7607 17.3408 17.1738 16.8926 17.1738 16.3037C17.1738 16.0225 17.0684 15.7676 16.8838 15.583L12.5859 11.2588C13.4912 10.0811 14.0273 8.61328 14.0273 7.01367C14.0273 3.14648 10.8809 0 7.01367 0C3.14648 0 0 3.14648 0 7.01367ZM1.50293 7.01367C1.50293 3.97266 3.97266 1.50293 7.01367 1.50293C10.0547 1.50293 12.5244 3.97266 12.5244 7.01367C12.5244 10.0547 10.0547 12.5244 7.01367 12.5244C3.97266 12.5244 1.50293 10.0547 1.50293 7.01367Z" />
</svg>
<div className="ml-6">
Searching for <InlineSearchTerm term={pattern} /> on{" "}
{cursorToPage(cursor, browserToolResult)}
</div>
</div>
);
}
return (
<div>
<code>name: {toolCall.function.name}</code>
<pre>
<code>args: {toolCall.function.arguments}</code>
</pre>
</div>
);
}
function ToolCallDisplay({
toolCall,
browserToolResult,
}: {
toolCall: ToolCall;
browserToolResult?: BrowserToolResult;
}) {
const [isCollapsed, setIsCollapsed] = React.useState(true);
// frontend tool call display for web_search
if (toolCall.function.name === "web_search") {
let args: Record<string, unknown> | null = null;
try {
args = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
} catch (e) {
args = null;
}
const query = args && typeof args.query === "string" ? args.query : "";
return (
<div className="text-neutral-600 dark:text-neutral-400 relative select-text">
{/* Magnifying Glass Icon */}
<svg
className="fill-current h-4 absolute top-1"
viewBox="0 0 18 18"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 7.01367C0 10.8809 3.14648 14.0273 7.01367 14.0273C8.54297 14.0273 9.94043 13.5352 11.0918 12.709L15.416 17.042C15.6182 17.2441 15.8818 17.3408 16.1631 17.3408C16.7607 17.3408 17.1738 16.8926 17.1738 16.3037C17.1738 16.0225 17.0684 15.7676 16.8838 15.583L12.5859 11.2588C13.4912 10.0811 14.0273 8.61328 14.0273 7.01367C14.0273 3.14648 10.8809 0 7.01367 0C3.14648 0 0 3.14648 0 7.01367ZM1.50293 7.01367C1.50293 3.97266 3.97266 1.50293 7.01367 1.50293C10.0547 1.50293 12.5244 3.97266 12.5244 7.01367C12.5244 10.0547 10.0547 12.5244 7.01367 12.5244C3.97266 12.5244 1.50293 10.0547 1.50293 7.01367Z" />
</svg>
<div className="ml-6">
Searching for <InlineSearchTerm term={query} />
&#8230;
</div>
</div>
);
}
if (toolCall.function.name === "web_fetch") {
let args: Record<string, unknown> | null = null;
try {
args = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
} catch (e) {
args = null;
}
const url = args && typeof args.url === "string" ? args.url : "";
return (
<div className="text-neutral-600 dark:text-neutral-400 relative select-text">
{/* Magnifying Glass Icon */}
<svg
className="fill-current h-4 absolute top-1"
viewBox="0 0 18 18"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 7.01367C0 10.8809 3.14648 14.0273 7.01367 14.0273C8.54297 14.0273 9.94043 13.5352 11.0918 12.709L15.416 17.042C15.6182 17.2441 15.8818 17.3408 16.1631 17.3408C16.7607 17.3408 17.1738 16.8926 17.1738 16.3037C17.1738 16.0225 17.0684 15.7676 16.8838 15.583L12.5859 11.2588C13.4912 10.0811 14.0273 8.61328 14.0273 7.01367C14.0273 3.14648 10.8809 0 7.01367 0C3.14648 0 0 3.14648 0 7.01367ZM1.50293 7.01367C1.50293 3.97266 3.97266 1.50293 7.01367 1.50293C10.0547 1.50293 12.5244 3.97266 12.5244 7.01367C12.5244 10.0547 10.0547 12.5244 7.01367 12.5244C3.97266 12.5244 1.50293 10.0547 1.50293 7.01367Z" />
</svg>
<div className="ml-6">
Fetching for <InlineSearchTerm term={url} />
&#8230;
</div>
</div>
);
}
if (!toolCall.function.name.startsWith("browser.")) {
let preview = "";
// preview from the tool's JSON arguments.
try {
const argsObj = JSON.parse(toolCall.function.arguments) as Record<
string,
unknown
>;
const preferredKey = [
"query",
"url",
"pattern",
"id",
"file",
"path",
].find((k) => Object.prototype.hasOwnProperty.call(argsObj, k));
if (preferredKey && typeof (argsObj as any)[preferredKey] === "string") {
preview = String((argsObj as any)[preferredKey]);
}
} catch (err) {
console.error(
"Failed to parse toolCall.function.arguments in Message.tsx:",
err,
);
}
return (
<div className="text-neutral-600 dark:text-neutral-400 relative select-text">
<svg
className="h-4 w-4 absolute top-1.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
<div className="ml-6">
Calling <span className="font-mono">{toolCall.function.name}</span>
{preview ? (
<>
: <InlineSearchTerm term={preview} />
</>
) : null}
&#8230;
</div>
</div>
);
}
if (toolCall.function.name.startsWith("browser.")) {
return (
<BrowserToolCallDisplay
toolCall={toolCall}
browserToolResult={browserToolResult}
/>
);
}
let parsedArgs = null;
try {
parsedArgs = JSON.parse(toolCall.function.arguments);
} catch {
parsedArgs = toolCall.function.arguments;
}
// Create a compact preview of arguments as a string
const getArgsPreview = () => {
if (!parsedArgs || typeof parsedArgs !== "object") {
return parsedArgs ? String(parsedArgs) : "";
}
const argPairs = Object.entries(parsedArgs)
.map(([key, value]) => {
let displayValue;
if (typeof value === "string") {
displayValue = `"${value}"`;
} else if (typeof value === "object") {
displayValue = JSON.stringify(value);
} else {
displayValue = String(value);
}
return `${key}=${displayValue}`;
})
.join(", ");
return argPairs;
};
return (
<div
className={`flex flex-col w-full ${!isCollapsed ? "text-neutral-800 dark:text-neutral-200" : "text-neutral-600 dark:text-neutral-400"}
hover:text-neutral-800
dark:hover:text-neutral-200 transition-colors`}
>
<div
className="flex items-center cursor-pointer group/tool self-start relative"
onClick={() => setIsCollapsed(!isCollapsed)}
>
{/* Tool icon */}
<svg
className={`w-3 absolute left-0 top-1/2 -translate-y-1/2 transition-opacity ${
isCollapsed ? "opacity-100" : "opacity-0"
} group-hover/tool:opacity-0 fill-current will-change-opacity`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
{/* Arrow */}
<svg
className={`h-4 w-4 absolute transition-all ${
isCollapsed
? "-rotate-90 opacity-0 group-hover/tool:opacity-100"
: "rotate-0 opacity-100"
} will-change-[opacity,transform]`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<h3 className="ml-6 font-mono text-sm">
<span className="font-semibold">{toolCall.function.name}</span>
{isCollapsed && parsedArgs && (
<span className="text-neutral-500 dark:text-neutral-500 ml-1">
({getArgsPreview()})
</span>
)}
<span className="text-neutral-500 dark:text-neutral-500 ml-2 text-xs">
{toolCall.type}
</span>
</h3>
</div>
<div
className={`text-xs text-neutral-500 dark:text-neutral-500 rounded-md
transition-[max-height,opacity] duration-300 ease-in-out ml-6 mt-2`}
style={{
maxHeight: isCollapsed ? "0px" : "40rem",
opacity: isCollapsed ? 0 : 1,
}}
>
<div className="transition-transform duration-300 opacity-75">
{parsedArgs && (
<div className="mb-4">
<div className="text-xs font-semibold text-neutral-600 dark:text-neutral-400 mb-1">
Arguments:
</div>
<pre className="text-xs bg-neutral-100 dark:bg-neutral-800 p-2 rounded overflow-x-auto">
<code className="text-neutral-800 dark:text-neutral-200">
{typeof parsedArgs === "object"
? JSON.stringify(parsedArgs, null, 2)
: parsedArgs}
</code>
</pre>
</div>
)}
{toolCall.function.result && (
<div>
<div className="text-xs font-semibold text-neutral-600 dark:text-neutral-400 mb-1">
Result:
</div>
<pre className="text-xs bg-neutral-100 dark:bg-neutral-800 p-2 rounded overflow-x-auto max-h-40">
<code className="text-neutral-800 dark:text-neutral-200">
{typeof toolCall.function.result === "object"
? JSON.stringify(toolCall.function.result, null, 2)
: toolCall.function.result}
</code>
</pre>
</div>
)}
</div>
</div>
</div>
);
}
function UserMessage({
message,
onEditMessage,
messageIndex,
isFaded,
}: {
message: MessageType;
onEditMessage?: (content: string, index: number) => void;
messageIndex?: number;
isFaded?: boolean;
}) {
const handleEdit = () => {
if (onEditMessage && messageIndex !== undefined) {
onEditMessage(message.content, messageIndex);
}
};
return (
<div
className={`flex flex-col transition-opacity duration-300 ${isFaded ? "opacity-50" : "opacity-100"}`}
>
{/* Show image attachments above the message background */}
{message.attachments && message.attachments.length > 0 && (
<div className="flex gap-2 mb-2 overflow-x-auto justify-end max-w-md self-end">
{message.attachments
.filter((attachment: File) => isImageFile(attachment.filename))
.map((attachment: File, index: number) => (
<div key={`image-attachment-${index}`} className="flex-shrink-0">
<ImageThumbnail
image={attachment}
className="w-16 h-16 object-cover rounded-md"
/>
</div>
))}
</div>
)}
<div className="message-container mb-8 max-w-md self-end">
<div
className="message rounded-3xl bg-neutral-100 px-4 py-2 leading-normal
dark:bg-neutral-700 dark:text-white group/message relative"
data-role="user"
>
{/* Show non-image attachments inside the message */}
{message.attachments &&
message.attachments.some(
(attachment: File) => !isImageFile(attachment.filename),
) && (
<div className="flex gap-2 mb-2 overflow-x-auto">
{message.attachments
.filter(
(attachment: File) => !isImageFile(attachment.filename),
)
.map((attachment: File, index: number) => (
<div
key={`file-attachment-${index}`}
className="flex items-center gap-2 py-1 px-2 rounded-lg bg-neutral-50 dark:bg-neutral-600/50 transition-colors flex-shrink-0"
>
<svg
className="w-3 h-3 text-neutral-400 dark:text-neutral-500 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span className="text-xs text-neutral-600 dark:text-neutral-400 max-w-[120px] truncate">
{attachment.filename}
</span>
</div>
))}
</div>
)}
<div className="message-content whitespace-pre-line break-words">
{message.content}
</div>
{/* Edit button */}
<button
type="button"
className={`edit-button absolute -bottom-5 right-1 text-xs
${
isFaded
? "opacity-30"
: "opacity-0 group-hover/message:opacity-100 text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200 cursor-pointer"
}`}
onClick={isFaded ? undefined : handleEdit}
>
edit
</button>
</div>
</div>
</div>
);
}
function OtherRoleMessage({
message,
isStreaming,
isFaded,
browserToolResult,
lastToolQuery,
}: {
message: MessageType;
previousMessage?: MessageType;
isStreaming: boolean;
isFaded?: boolean;
// TODO(drifkin): this type isn't right
browserToolResult?: BrowserToolResult;
lastToolQuery?: string;
}) {
const messageRef = useRef<HTMLDivElement>(null);
return (
<div
className={`flex mb-8 flex-col transition-opacity duration-300 space-y-4 ${isFaded ? "opacity-50" : "opacity-100"}`}
>
<div className="flex-1 flex flex-col justify-start relative group max-w-none text-wrap break-words">
{/* Thinking area */}
{message.thinking && (
<Thinking
thinking={message.thinking}
startTime={message.thinkingTimeStart}
endTime={message.thinkingTimeEnd}
/>
)}
{/* Only render content div if there's actual content to show */}
{(() => {
// Skip rendering content div for tool messages with structured tool_calls
if (
message.role === "tool" &&
message.tool_calls &&
message.tool_calls.length > 0
) {
return null;
}
if (
message.role !== "tool" &&
(!message.content || !message.content.trim())
) {
return null;
}
// Render appropriate content
return (
<div
className="max-w-full prose dark:prose-invert assistant-message-content break-words"
id="message-container"
ref={messageRef}
>
{message.role === "tool" ? (
<ToolRoleContent
message={message}
browserToolResult={browserToolResult}
lastToolQuery={lastToolQuery}
/>
) : (
<StreamingMarkdownContent
content={message.content}
isStreaming={isStreaming}
browserToolResult={browserToolResult as BrowserToolResult}
/>
)}
</div>
);
})()}
</div>
{message.tool_calls && message.tool_calls.length > 0 && (
<div>
{message.tool_calls.map((toolCall: ToolCall, index: number) => (
<ToolCallDisplay
key={index}
toolCall={toolCall}
browserToolResult={browserToolResult}
/>
))}
</div>
)}
{message.tool_call && (
<ToolCallDisplay
toolCall={message.tool_call}
browserToolResult={browserToolResult}
/>
)}
{!isStreaming &&
message.role === "assistant" &&
message.content &&
message.content.trim() &&
(!message.tool_calls || message.tool_calls.length === 0) &&
!message.tool_call && (
<div className="-ml-1">
<CopyButton
content={message.content || ""}
copyRef={messageRef as React.RefObject<HTMLElement>}
removeClasses={["copy-button"]}
size="md"
showLabels={false}
className="copy-button z-10 text-neutral-500 dark:text-neutral-400"
title="Copy"
/>
</div>
)}
</div>
);
}
import { Message as MessageType, DownloadEvent, ErrorEvent } from "@/gotypes";
import React from "react";
import Message from "./Message";
import Downloading from "./Downloading";
import { ErrorMessage } from "./ErrorMessage";
export default function MessageList({
messages,
spacerHeight,
isWaitingForLoad,
isStreaming,
downloadProgress,
onEditMessage,
editingMessageIndex,
error,
browserToolResult,
}: {
messages: MessageType[];
spacerHeight: number;
isWaitingForLoad?: boolean;
isStreaming: boolean;
downloadProgress?: DownloadEvent;
onEditMessage?: (content: string, index: number) => void | Promise<void>;
editingMessageIndex?: number;
error?: ErrorEvent | null;
browserToolResult?: any;
}) {
const [showDots, setShowDots] = React.useState(false);
const isDownloadingModel = downloadProgress && !downloadProgress.done;
const shouldShowDownload = messages.length > 0;
React.useEffect(() => {
let timer: number;
if (
(isStreaming || isWaitingForLoad) &&
!isDownloadingModel &&
messages.length > 0 &&
messages[messages.length - 1]?.role === "user"
) {
timer = window.setTimeout(() => {
setShowDots(true);
}, 750); // Wait 750ms before showing dots
} else {
setShowDots(false);
}
return () => window.clearTimeout(timer);
}, [isStreaming, isWaitingForLoad, isDownloadingModel, messages]);
const lastIdx = messages.length - 1;
// Memoize the last tool query (web_search query or web_fetch url) at each message index
const lastToolQueries = React.useMemo(() => {
const queries: (string | undefined)[] = [];
let lastQuery: string | undefined = undefined;
for (let i = 0; i < messages.length; i++) {
const m: any = messages[i] as any;
const toolCalls: any[] | undefined = Array.isArray(m?.tool_calls)
? (m.tool_calls as any[])
: m?.tool_call
? [m.tool_call]
: undefined;
if (toolCalls && toolCalls.length > 0) {
for (const tc of toolCalls) {
const name = tc?.function?.name;
if (name === "web_search" || name === "web_fetch") {
try {
const args = JSON.parse(tc.function.arguments || "{}");
const candidate =
typeof args.query === "string" && args.query.trim()
? String(args.query).trim()
: typeof args.url === "string" && args.url.trim()
? String(args.url).trim()
: "";
if (candidate) lastQuery = candidate;
} catch {}
}
}
}
queries.push(lastQuery);
}
return queries;
}, [messages]);
return (
<div
className="mx-auto flex max-w-[768px] flex-1 flex-col px-6 pb-12 select-text"
data-role="message-list"
>
{messages.map((message, idx) => {
const lastToolQuery = lastToolQueries[idx];
return (
<div key={`${message.created_at}-${idx}`} data-message-index={idx}>
<Message
message={message}
onEditMessage={onEditMessage}
messageIndex={idx}
isStreaming={isStreaming && idx === lastIdx}
isFaded={
editingMessageIndex !== undefined && idx >= editingMessageIndex
}
browserToolResult={browserToolResult}
lastToolQuery={lastToolQuery}
/>
</div>
);
})}
{/* Inline error message */}
{error &&
error.code !== "usage_limit_upgrade" &&
error.code !== "cloud_unauthorized" && <ErrorMessage error={error} />}
{/* Indeterminate loading indicator */}
{showDots && (
<div className="flex items-center space-x-1.5 py-3 mt-2 self-start rounded-full px-4 min-w-0 bg-neutral-100 dark:bg-neutral-800 border-neutral-200 dark:border-neutral-700">
<div
className="w-1.5 h-1.5 bg-neutral-400 dark:bg-neutral-500 rounded-full opacity-0"
style={{
animation: "typing 1.4s infinite",
animationDelay: "0s",
}}
/>
<div
className="w-1.5 h-1.5 bg-neutral-400 dark:bg-neutral-500 rounded-full opacity-0"
style={{
animation: "typing 1.4s infinite",
animationDelay: "0.15s",
}}
/>
<div
className="w-1.5 h-1.5 bg-neutral-400 dark:bg-neutral-500 rounded-full opacity-0"
style={{
animation: "typing 1.4s infinite",
animationDelay: "0.3s",
}}
/>
</div>
)}
{/* Downloading model */}
{/* Only show for models larger than 1KiB */}
{downloadProgress?.total && downloadProgress.total > 1024 && (
<section
className={`
transition-all ease-out
${shouldShowDownload ? "duration-300" : "duration-0"}
${
downloadProgress
? downloadProgress.done
? "opacity-0 -translate-y-8"
: "opacity-100 translate-y-0"
: "opacity-0 translate-y-4 pointer-events-none"
}
`}
>
<Downloading
completed={downloadProgress?.completed || 0}
total={downloadProgress?.total || 0}
/>
</section>
)}
{/* Dynamic spacer to allow scrolling the last message to the top of the container */}
<div style={{ height: `${spacerHeight}px` }} aria-hidden="true" />
</div>
);
}
import {
useState,
useRef,
useEffect,
forwardRef,
type JSX,
useImperativeHandle,
} from "react";
import { Model } from "@/gotypes";
import { useSelectedModel } from "@/hooks/useSelectedModel";
import { useSettings } from "@/hooks/useSettings";
import { useQueryClient } from "@tanstack/react-query";
import { getModelUpstreamInfo } from "@/api";
import { ArrowDownTrayIcon } from "@heroicons/react/24/outline";
const stalenessCheckCache = new Map<string, number>();
export const ModelPicker = forwardRef<
HTMLButtonElement,
{
chatId?: string;
onModelSelect?: () => void;
onEscape?: () => void;
onDropdownToggle?: (isOpen: boolean) => void;
isDisabled?: boolean;
}
>(function ModelPicker(
{ chatId, onModelSelect, onEscape, onDropdownToggle, isDisabled },
ref,
): JSX.Element {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const { selectedModel, setSettings, models, loading } = useSelectedModel(
chatId,
searchQuery,
);
const { settings } = useSettings();
const dropdownRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient();
const modelListRef = useRef<{
scrollToSelectedModel: () => void;
scrollToTop: () => void;
}>(null);
const checkModelStaleness = async (model: Model) => {
if (
!model ||
!model.model ||
model.digest === undefined ||
model.digest === ""
)
return;
// Check cache - only check staleness every 5 minutes per model
const now = Date.now();
const lastChecked = stalenessCheckCache.get(model.model);
if (lastChecked && now - lastChecked < 5 * 60 * 1000) return;
stalenessCheckCache.set(model.model, now);
try {
const upstreamInfo = await getModelUpstreamInfo(model);
// Compare local digest with upstream digest
let isStale =
model.digest &&
upstreamInfo.digest &&
model.digest !== upstreamInfo.digest;
// If the model has a modified time and upstream has a push time,
// check if the model was modified after the push time - if so, it's not stale
if (isStale && model.modified_at && upstreamInfo.pushTime > 0) {
const modifiedAtTime =
new Date(model.modified_at as string | number | Date).getTime() /
1000;
if (modifiedAtTime > upstreamInfo.pushTime) {
isStale = false;
}
}
if (isStale) {
const currentStaleModels =
queryClient.getQueryData<Map<string, boolean>>(["staleModels"]) ||
new Map();
const newMap = new Map(currentStaleModels);
newMap.set(model.model, true);
queryClient.setQueryData(["staleModels"], newMap);
}
} catch (error) {
console.error("Failed to check model staleness:", error);
}
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
useEffect(() => {
if (ref && typeof ref === "object" && ref.current) {
(ref.current as any).closeDropdown = () => setIsOpen(false);
}
}, [ref, setIsOpen]);
// Focus search when opened and refresh models
// Clear search when closed
useEffect(() => {
if (isOpen) {
searchInputRef.current?.focus();
modelListRef.current?.scrollToSelectedModel();
} else {
setSearchQuery("");
}
}, [isOpen]);
// When searching, scroll to top of list
useEffect(() => {
if (searchQuery && modelListRef.current) {
modelListRef.current.scrollToTop();
}
}, [searchQuery]);
useEffect(() => {
if (selectedModel && !loading) {
checkModelStaleness(selectedModel);
}
}, [selectedModel?.model, loading]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isOpen) return;
if (event.key === "Escape") {
event.preventDefault();
setIsOpen(false);
onEscape?.();
return;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onEscape]);
const handleModelSelect = (model: Model) => {
setSettings({ SelectedModel: model.model });
setIsOpen(false);
onModelSelect?.();
};
return (
<div className="relative" ref={dropdownRef}>
<button
ref={ref}
type="button"
title="Select model"
onClick={() => {
const newState = !isOpen;
setIsOpen(newState);
onDropdownToggle?.(newState);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const newState = !isOpen;
setIsOpen(newState);
onDropdownToggle?.(newState);
}
}}
onMouseDown={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
className="flex items-center select-none gap-1.5 rounded-full px-3.5 py-1.5 bg-white dark:bg-neutral-700 text-neutral-800 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:text-neutral-100 cursor-pointer"
>
<div className="flex items-center gap-2">
<span>
{isDisabled
? "Loading..."
: selectedModel?.model || "Select a model"}
</span>
</div>
<svg
className="h-3 w-3 opacity-70"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{isOpen && (
<div className="absolute right-0 text-[15px] bottom-full mb-2 z-50 w-64 rounded-2xl overflow-hidden bg-white border border-neutral-100 text-neutral-800 shadow-xl shadow-black/5 backdrop-blur-lg dark:border-neutral-600/40 dark:bg-neutral-800 dark:text-white dark:ring-black/20">
<div className="px-1 py-2 border-b border-neutral-100 dark:border-neutral-700">
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Find model..."
autoCorrect="off"
className="w-full px-2 py-0.5 bg-transparent border-none border-neutral-200 rounded-md outline-none focus:border-neutral-400 dark:border-neutral-600 dark:focus:border-neutral-400"
/>
</div>
<ModelList
ref={modelListRef}
models={models}
selectedModel={selectedModel}
onModelSelect={handleModelSelect}
airplaneMode={settings.airplaneMode}
isOpen={isOpen}
/>
</div>
)}
</div>
);
});
export const ModelList = forwardRef(function ModelList(
{
models,
selectedModel,
onModelSelect,
airplaneMode,
isOpen,
}: {
models: Model[];
selectedModel: Model | null;
onModelSelect: (model: Model) => void;
airplaneMode: boolean;
isOpen: boolean;
},
ref,
): JSX.Element {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
useImperativeHandle(ref, () => ({
scrollToSelectedModel: () => {
if (!selectedModel || !scrollContainerRef.current) return;
const selectedIndex = models.findIndex(
(m) => m.model === selectedModel.model,
);
if (selectedIndex !== -1) scrollToItem(selectedIndex);
},
scrollToTop: () => {
if (scrollContainerRef.current) scrollContainerRef.current.scrollTop = 0;
},
}));
// Handle keyboard navigation
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isOpen || models.length === 0) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
setHighlightedIndex((prev) => {
const next = prev < models.length - 1 ? prev + 1 : 0;
scrollToItem(next);
return next;
});
break;
case "ArrowUp":
event.preventDefault();
setHighlightedIndex((prev) => {
const next = prev > 0 ? prev - 1 : models.length - 1;
scrollToItem(next);
return next;
});
break;
case "Enter":
event.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < models.length) {
onModelSelect(models[highlightedIndex]);
}
break;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, models, highlightedIndex, onModelSelect]);
// Scroll active item into view
const scrollToItem = (index: number) => {
if (scrollContainerRef.current && index >= 0) {
const container = scrollContainerRef.current;
const item = container.children[index] as HTMLElement;
if (item) {
// Calculate the exact scroll position to center the item
const containerHeight = container.clientHeight;
const itemTop = item.offsetTop;
const itemHeight = item.clientHeight;
// Position the item in the center of the container
container.scrollTop = itemTop - containerHeight / 2 + itemHeight / 2;
}
}
};
return (
<div
ref={scrollContainerRef}
className="h-64 overflow-y-auto overflow-x-hidden"
>
{models.length === 0 ? (
<div className="px-3 py-2 text-neutral-500 dark:text-neutral-400">
No models found
</div>
) : (
models.map((model, index) => {
return (
<div key={`${model.model}-${model.digest || "no-digest"}-${index}`}>
<button
onClick={() => onModelSelect(model)}
onMouseEnter={() => setHighlightedIndex(index)}
className={`flex w-full items-center gap-2 px-3 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-700/60 focus:outline-none cursor-pointer ${
highlightedIndex === index ||
selectedModel?.model === model.model
? "bg-neutral-100 dark:bg-neutral-700/60"
: ""
}`}
>
<span className="flex-1 text-left truncate min-w-0">
{model.model}
</span>
{model.isCloud() && (
<svg
className="h-3 fill-current text-neutral-500 dark:text-neutral-400"
viewBox="0 0 20 15"
strokeWidth={1}
xmlns="http://www.w3.org/2000/svg"
>
<path d="M4.01511 14.5861H14.2304C16.9183 14.5861 19.0002 12.5509 19.0002 9.9403C19.0002 7.30491 16.8911 5.3046 14.0203 5.3046C12.9691 3.23016 11.0602 2 8.69505 2C5.62816 2 3.04822 4.32758 2.72935 7.47455C1.12954 7.95356 0.0766602 9.29431 0.0766602 10.9757C0.0766602 12.9913 1.55776 14.5861 4.01511 14.5861ZM4.02056 13.1261C2.46452 13.1261 1.53673 12.2938 1.53673 11.0161C1.53673 9.91553 2.24207 9.12934 3.51367 8.79302C3.95684 8.68258 4.11901 8.48427 4.16138 8.00729C4.39317 5.3613 6.29581 3.46007 8.69505 3.46007C10.5231 3.46007 11.955 4.48273 12.8385 6.26013C13.0338 6.65439 13.2626 6.7882 13.7488 6.7882C16.1671 6.7882 17.5337 8.19719 17.5337 9.97707C17.5337 11.7526 16.1242 13.1261 14.2852 13.1261H4.02056Z" />
</svg>
)}
{model.digest === undefined &&
(airplaneMode || !model.isCloud()) && (
<ArrowDownTrayIcon
className="h-4 w-4 text-neutral-500 dark:text-neutral-400"
strokeWidth={1.75}
/>
)}
</button>
</div>
);
})
)}
</div>
);
});
import { useEffect, useState, useCallback } from "react";
import { Switch } from "@/components/ui/switch";
import { Text } from "@/components/ui/text";
import { Input } from "@/components/ui/input";
import { Field, Label, Description } from "@/components/ui/fieldset";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import {
WifiIcon,
FolderIcon,
BoltIcon,
WrenchIcon,
XMarkIcon,
CogIcon,
ArrowLeftIcon,
} from "@heroicons/react/20/solid";
import { Settings as SettingsType } from "@/gotypes";
import { useNavigate } from "@tanstack/react-router";
import { useUser } from "@/hooks/useUser";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getSettings, updateSettings } from "@/api";
function AnimatedDots() {
return (
<span className="inline-flex">
<span className="animate-pulse">.</span>
<span className="animate-pulse" style={{ animationDelay: "0.2s" }}>
.
</span>
<span className="animate-pulse" style={{ animationDelay: "0.4s" }}>
.
</span>
</span>
);
}
export default function Settings() {
const queryClient = useQueryClient();
const [showSaved, setShowSaved] = useState(false);
const [restartMessage, setRestartMessage] = useState(false);
const {
user,
isAuthenticated,
refreshUser,
isRefreshing,
refetchUser,
fetchConnectUrl,
isLoading,
disconnectUser,
} = useUser();
const [isAwaitingConnection, setIsAwaitingConnection] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [pollingInterval, setPollingInterval] = useState<number | null>(null);
const navigate = useNavigate();
const {
data: settingsData,
isLoading: loading,
error,
} = useQuery({
queryKey: ["settings"],
queryFn: getSettings,
});
const settings = settingsData?.settings || null;
const updateSettingsMutation = useMutation({
mutationFn: updateSettings,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
setShowSaved(true);
setTimeout(() => setShowSaved(false), 1500);
},
});
useEffect(() => {
refetchUser();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const handleFocus = () => {
if (isAwaitingConnection && pollingInterval) {
// Stop polling when window gets focus
clearInterval(pollingInterval);
setPollingInterval(null);
// Reset awaiting connection state
setIsAwaitingConnection(false);
// Make one last refresh request
refreshUser();
}
};
window.addEventListener("focus", handleFocus);
return () => {
window.removeEventListener("focus", handleFocus);
};
}, [isAwaitingConnection, refreshUser, pollingInterval]);
// Check if user is authenticated after refresh
useEffect(() => {
if (isAwaitingConnection && isAuthenticated) {
setIsAwaitingConnection(false);
setConnectionError(null);
if (pollingInterval) {
clearInterval(pollingInterval);
setPollingInterval(null);
}
}
}, [isAuthenticated, isAwaitingConnection, pollingInterval]);
// Cleanup interval on unmount
useEffect(() => {
return () => {
if (pollingInterval) {
clearInterval(pollingInterval);
}
};
}, [pollingInterval]);
const handleChange = useCallback(
(field: keyof SettingsType, value: boolean | string | number) => {
if (settings) {
const updatedSettings = new SettingsType({
...settings,
[field]: value,
});
// If context length is being changed, show restart message
if (field === "ContextLength" && value !== settings.ContextLength) {
setRestartMessage(true);
// Hide restart message after 3 seconds
setTimeout(() => setRestartMessage(false), 3000);
}
updateSettingsMutation.mutate(updatedSettings);
}
},
[settings, updateSettingsMutation],
);
const handleResetToDefaults = () => {
if (settings) {
const defaultSettings = new SettingsType({
Expose: false,
Browser: false,
Models: "",
Agent: false,
Tools: false,
ContextLength: 4096,
AirplaneMode: false,
});
updateSettingsMutation.mutate(defaultSettings);
}
};
const handleConnectOllamaAccount = async () => {
setConnectionError(null);
// If user is already authenticated, no need to connect
if (isAuthenticated) {
return;
}
try {
// If we don't have a user or user has no name, get connect URL
if (!user || !user?.name) {
const { data: connectUrl } = await fetchConnectUrl();
if (connectUrl) {
window.open(connectUrl, "_blank");
setIsAwaitingConnection(true);
// Start polling every 5 seconds
const interval = setInterval(() => {
refreshUser();
}, 5000);
setPollingInterval(interval);
} else {
setConnectionError("Failed to get connect URL");
}
}
} catch (error) {
console.error("Error connecting to Ollama account:", error);
setConnectionError(
error instanceof Error
? error.message
: "Failed to connect to Ollama account",
);
setIsAwaitingConnection(false);
}
};
if (loading) {
return null;
}
if (error || !settings) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-red-500">Failed to load settings</div>
</div>
);
}
const isWindows = navigator.platform.toLowerCase().includes("win");
return (
<main className="flex h-screen w-full flex-col select-none dark:bg-neutral-900">
<header
className="w-full flex flex-none justify-between h-[52px] py-2.5 items-center border-b border-neutral-200 dark:border-neutral-800 select-none"
onMouseDown={() => window.drag && window.drag()}
onDoubleClick={() => window.doubleClick && window.doubleClick()}
>
<h1
className={`${isWindows ? "pl-4" : "pl-24"} flex items-center font-rounded text-md font-medium dark:text-white`}
>
{isWindows && (
<button
onClick={() => navigate({ to: "/" })}
className="hover:bg-neutral-100 mr-3 dark:hover:bg-neutral-800 rounded-full p-1.5"
>
<ArrowLeftIcon className="w-5 h-5 dark:text-white" />
</button>
)}
Settings
</h1>
{!isWindows && (
<button
onClick={() => navigate({ to: "/" })}
className="p-1 hover:bg-neutral-100 mr-3 dark:hover:bg-neutral-800 rounded-full"
>
<XMarkIcon className="w-6 h-6 dark:text-white" />
</button>
)}
</header>
<div className="w-full p-6 overflow-y-auto flex-1 overscroll-contain">
<div className="space-y-4 max-w-2xl mx-auto">
{/* Connect Ollama Account */}
<div className="overflow-hidden rounded-xl bg-white dark:bg-neutral-800">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800">
<Field>
{isLoading ? (
// Loading skeleton, this will only happen if the app started recently
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="h-4 bg-neutral-200 dark:bg-neutral-700 rounded animate-pulse w-24"></div>
<div className="h-3 bg-neutral-200 dark:bg-neutral-700 rounded animate-pulse w-32"></div>
</div>
<div className="h-10 w-10 bg-neutral-200 dark:bg-neutral-700 rounded-full animate-pulse"></div>
</div>
) : user && user.name ? (
<div className="flex items-center justify-between">
<div>
<div className="flex items-center space-x-2">
<Label className="text-sm font-medium text-neutral-900 dark:text-white">
{user?.name}
</Label>
</div>
<Description className="text-sm text-neutral-500 dark:text-neutral-400">
{user?.email}
</Description>
<div className="flex items-center space-x-2 mt-2">
{user?.plan === "free" && (
<Button
type="button"
color="dark"
className="px-3 py-2 text-sm font-medium bg-black/90 backdrop-blur-sm text-white rounded-lg border border-white/10 shadow-2xl transition-all duration-300 ease-out relative overflow-hidden group"
onClick={() =>
window.open(
"https://ollama.com/upgrade",
"_blank",
)
}
>
<div className="absolute inset-0 bg-gradient-to-r from-cyan-500/20 via-purple-500/20 to-green-500/20 opacity-60 group-hover:opacity-80 transition-opacity duration-300"></div>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000 ease-out"></div>
<span className="relative z-10 flex items-center space-x-2">
<span>Upgrade</span>
</span>
</Button>
)}
<Button
type="button"
color="white"
className="px-3 py-2 text-sm"
onClick={() =>
window.open("https://ollama.com/settings", "_blank")
}
>
Manage
</Button>
<Button
type="button"
color="zinc"
className="px-3 py-2 text-sm"
onClick={() => disconnectUser()}
>
Sign out
</Button>
</div>
</div>
{user?.avatarURL && (
<img
src={user.avatarURL}
alt={user?.name}
className="h-10 w-10 rounded-full bg-neutral-200 dark:bg-neutral-700 flex-shrink-0"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.className = "hidden";
}}
/>
)}
</div>
) : (
<div className="flex items-center justify-between">
<div>
<Label>Ollama account</Label>
<Description>Not connected</Description>
</div>
<Button
type="button"
color="white"
onClick={handleConnectOllamaAccount}
disabled={isRefreshing || isAwaitingConnection}
>
{isRefreshing || isAwaitingConnection ? (
<AnimatedDots />
) : (
"Sign In"
)}
</Button>
</div>
)}
</Field>
{connectionError && (
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<Text className="text-sm text-red-600 dark:text-red-400">
{connectionError}
</Text>
</div>
)}
</div>
</div>
{/* Local Configuration */}
<div className="relative overflow-hidden rounded-xl bg-white dark:bg-neutral-800">
<div className="space-y-4 p-4">
{/* Expose Ollama */}
<Field>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start space-x-3 flex-1">
<WifiIcon className="mt-1 h-5 w-5 flex-shrink-0 text-black dark:text-neutral-100" />
<div>
<Label>Expose Ollama to the network</Label>
<Description>
Allow other devices or services to access Ollama.
</Description>
</div>
</div>
<div className="flex-shrink-0">
<Switch
checked={settings.Expose}
onChange={(checked) => handleChange("Expose", checked)}
/>
</div>
</div>
</Field>
{/* Model Directory */}
<Field>
<div className="flex items-start space-x-3">
<FolderIcon className="mt-1 h-5 w-5 flex-shrink-0 text-black dark:text-neutral-100" />
<div className="w-full">
<Label>Model location</Label>
<Description>Location where models are stored.</Description>
<div className="mt-2 flex items-center space-x-2">
<Input
value={settings.Models || ""}
onChange={(e) => handleChange("Models", e.target.value)}
readOnly
/>
<Button
type="button"
color="white"
className="px-2"
onClick={async () => {
if (window.webview?.selectModelsDirectory) {
try {
const directory =
await window.webview.selectModelsDirectory();
if (directory) {
handleChange("Models", directory);
}
} catch (error) {
console.error(
"Error selecting models directory:",
error,
);
}
}
}}
>
<FolderIcon className="w-4 h-4 mr-1" />
Browse
</Button>
</div>
</div>
</div>
</Field>
{/* Context Length */}
<Field>
<div className="flex items-start space-x-3">
<CogIcon className="mt-1 h-5 w-5 flex-shrink-0 text-black dark:text-neutral-100" />
<div className="w-full">
<Label>Context length</Label>
<Description>
Context length determines how much of your conversation
local LLMs can remember and use to generate responses.
</Description>
<div className="mt-3">
<Slider
value={(() => {
// Otherwise use the settings value
return settings.ContextLength || 4096;
})()}
onChange={(value) => {
handleChange("ContextLength", value);
}}
options={[
{ value: 4096, label: "4k" },
{ value: 8192, label: "8k" },
{ value: 16384, label: "16k" },
{ value: 32768, label: "32k" },
{ value: 65536, label: "64k" },
{ value: 131072, label: "128k" },
{ value: 262144, label: "256k" },
]}
/>
</div>
</div>
</div>
</Field>
{/* Airplane Mode */}
<Field>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start space-x-3 flex-1">
<svg
className="mt-1 h-5 w-5 flex-shrink-0 text-black dark:text-neutral-100"
viewBox="0 0 21.5508 17.9033"
fill="currentColor"
>
<path d="M21.5508 8.94727C21.542 7.91895 20.1445 7.17188 18.4658 7.17188L14.9238 7.17188C14.4316 7.17188 14.2471 7.09277 13.957 6.75879L8.05078 0.316406C7.86621 0.105469 7.6377 0 7.37402 0L6.35449 0C6.12598 0 5.99414 0.202148 6.1084 0.448242L9.14941 7.17188L4.68457 7.68164L3.09375 4.76367C2.97949 4.54395 2.78613 4.44727 2.49609 4.44727L2.11816 4.44727C1.88965 4.44727 1.74023 4.59668 1.74023 4.8252L1.74023 13.0693C1.74023 13.2979 1.88965 13.4385 2.11816 13.4385L2.49609 13.4385C2.78613 13.4385 2.97949 13.3418 3.09375 13.1309L4.68457 10.2129L9.14941 10.7227L6.1084 17.4463C5.99414 17.6836 6.12598 17.8945 6.35449 17.8945L7.37402 17.8945C7.6377 17.8945 7.86621 17.7803 8.05078 17.5781L13.957 11.127C14.2471 10.8018 14.4316 10.7227 14.9238 10.7227L18.4658 10.7227C20.1445 10.7227 21.542 9.9668 21.5508 8.94727Z" />
</svg>
<div>
<Label>Airplane mode</Label>
<Description>
Airplane mode keeps data local, disabling cloud models
and web search.
</Description>
</div>
</div>
<div className="flex-shrink-0">
<Switch
checked={settings.AirplaneMode}
onChange={(checked) =>
handleChange("AirplaneMode", checked)
}
/>
</div>
</div>
</Field>
</div>
</div>
{/* Agent Mode */}
{window.OLLAMA_TOOLS && (
<div className="overflow-hidden rounded-xl bg-white dark:bg-neutral-800">
<div className="space-y-4 p-4">
<Field>
<div className="flex items-center justify-between">
<div className="flex items-start space-x-3">
<BoltIcon className="mt-1 h-5 w-5 flex-shrink-0 text-black dark:text-neutral-100" />
<div>
<Label>Enable Agent Mode</Label>
<Description>
Use multi-turn tools to fulfill user requests
</Description>
</div>
</div>
<Switch
checked={settings.Agent}
onChange={(checked) => handleChange("Agent", checked)}
/>
</div>
</Field>
{/* Tools Mode */}
<Field>
<div className="flex items-center justify-between">
<div className="flex items-start space-x-3">
<WrenchIcon className="mt-1 h-5 w-5 flex-shrink-0 text-black dark:text-neutral-100" />
<div>
<Label>Enable Tools Mode</Label>
<Description>
Use single-turn tools to fulfill user requests
</Description>
</div>
</div>
<Switch
checked={settings.Tools}
onChange={(checked) => handleChange("Tools", checked)}
/>
</div>
</Field>
</div>
</div>
)}
{/* Reset button */}
<div className="mt-6 flex justify-end px-4">
<Button
type="button"
color="white"
className="px-3"
onClick={handleResetToDefaults}
>
Reset to defaults
</Button>
</div>
</div>
{/* Saved indicator */}
{(showSaved || restartMessage) && (
<div className="fixed bottom-4 left-1/2 transform -translate-x-1/2 transition-opacity duration-300 z-50">
<Badge
color="green"
className="!bg-green-500 !text-white dark:!bg-green-600"
>
Saved
</Badge>
</div>
)}
</div>
</main>
);
}
import type { Meta, StoryObj } from "@storybook/react-vite";
import StreamingMarkdownContent from "./StreamingMarkdownContent";
import { useState, useEffect, useCallback } from "react";
import type { LastNodeInfo } from "@/utils/remarkStreamingMarkdown";
const meta = {
title: "Components/StreamingMarkdownContent",
component: StreamingMarkdownContent,
parameters: {
layout: "padded",
},
tags: ["autodocs"],
argTypes: {
content: {
description: "The markdown content to display",
},
isStreaming: {
description: "Whether the content is currently streaming",
},
size: {
description: "Size of the text",
options: ["sm", "md", "lg"],
control: { type: "select" },
},
},
} satisfies Meta<typeof StreamingMarkdownContent>;
export default meta;
type Story = StoryObj<typeof meta>;
// Basic static examples
export const Default: Story = {
args: {
content: "This is a simple markdown text without any special formatting.",
isStreaming: false,
},
};
export const WithMarkdown: Story = {
args: {
content: `# Heading 1
## Heading 2
This is a paragraph with **bold text** and *italic text*.
- List item 1
- List item 2
- List item 3
\`\`\`javascript
const hello = "world";
console.log(hello);
\`\`\``,
isStreaming: false,
},
};
export const WithMath: Story = {
args: {
content: `# Mathematical Expressions
## Inline Math
The quadratic formula is $x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$ which gives us the roots of a quadratic equation.
Here's Euler's identity: $e^{i\\pi} + 1 = 0$
## Display Math
The Gaussian integral:
$$\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}$$
Matrix multiplication:
$$
\\begin{bmatrix}
a & b \\\\
c & d
\\end{bmatrix}
\\begin{bmatrix}
x \\\\
y
\\end{bmatrix}
=
\\begin{bmatrix}
ax + by \\\\
cx + dy
\\end{bmatrix}
$$
## Mixed Content
Let's solve $ax^2 + bx + c = 0$. Using the quadratic formula mentioned above, we get:
$$x_{1,2} = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$$
For example, if $a = 1$, $b = -3$, and $c = 2$, then:
- Discriminant: $\\Delta = b^2 - 4ac = 9 - 8 = 1$
- Solutions: $x_1 = 2$ and $x_2 = 1$`,
isStreaming: false,
},
};
export const WithMathDelimiters: Story = {
args: {
content: `\\[ a = \\frac{b}{c} \\]
`,
isStreaming: false,
},
};
export const WithMathPartial: Story = {
args: {
content: `\\[ a = \\frac`,
isStreaming: false,
},
};
export const AmbiguousMath: Story = {
args: {
content: `**a b \\[ c ** def \\]`,
isStreaming: false,
},
};
export const MathEmbedded: Story = {
args: {
content: `Below is a quick “cheat‑sheet” of some of the most widely‑used equations in mathematics (and a few from physics that are heavily mathematical). \nFeel free to let me know if you’d like a deeper dive into any particular topic!\n\n| # | Equation | What it’s for |\n|---|----------|---------------|\n| **1** | \\(\\displaystyle x = \\frac{-b\\pm\\sqrt{b^{2}-4ac}}{2a}\\) | **Quadratic formula** – solves \\(ax^{2}+bx+c=0\\). |\n| **2** | \\(\\displaystyle a^{2}+b^{2}=c^{2}\\) | **Pythagorean theorem** – right‑triangle sides. |\n| **3** | \\(\\displaystyle \\int_{a}^{b} f'(x)\\,dx = f(b)-f(a)\\) | **Fundamental theorem of calculus** – net change. |\n| **4** | \\(\\displaystyle e^{i\\pi}+1=0\\) | **Euler’s identity** – links \\(e,i,\\pi\\) and the numbers 0, 1. |\n| **5** | \\(\\displaystyle A=\\pi r^{2}\\) | **Area of a circle**. |\n| **6** | \\(\\displaystyle N(t)=N_{0}e^{kt}\\) | **Exponential growth/decay** (e.g., population, radioactivity). |\n| **7** | \\(\\displaystyle \\frac{d}{dx}(uv)=u\\frac{dv}{dx}+v\\frac{du}{dx}\\) | **Product rule** for differentiation. |\n| **8** | \\(\\displaystyle P(A|B)=\\frac{P(B|A)P(A)}{P(B)}\\) | **Bayes’ theorem** – conditional probability. |\n| **9** | \\(\\displaystyle F=ma\\) | **Newton’s second law** – force = mass × acceleration. |\n| **10** | \\(\\displaystyle E=mc^{2}\\) | **Mass‑energy equivalence** (relativity). |\n| **11** | \\(\\displaystyle f(x)=\\frac{1}{\\sigma\\sqrt{2\\pi}}\\exp\\!\\Big(-\\frac{(x-\\mu)^{2}}{2\\sigma^{2}}\\Big)\\) | **Gaussian (normal) distribution**. |\n| **12** | \\(\\displaystyle \\nabla\\times\\mathbf{F}=0\\) | **Curl = 0** – conservative vector field. |\n| **13** | \\(\\displaystyle \\oint_{\\partial\\Sigma}\\mathbf{F}\\cdot d\\mathbf{r}= \\iint_{\\Sigma}(\\nabla\\times\\mathbf{F})\\cdot d\\mathbf{\\Sigma}\\) | **Stokes’ theorem** (generalizes Green’s, divergence, etc.). |\n| **14** | \\(\\displaystyle \\sum_{k=0}^{n}k=\\frac{n(n+1)}{2}\\) | **Sum of the first \\(n\\) natural numbers**. |\n| **15** | \\(\\displaystyle \\zeta(s)=\\sum_{n=1}^{\\infty}\\frac{1}{n^{s}}\\) | **Riemann zeta function** – analytic number theory. |\n\n---\n\n### Quick “One‑Liners” from Other Fields\n\n| Field | Equation | Short note |\n|-------|----------|------------|\n| **Statistics** | \\(\\displaystyle \\bar{x}=\\frac{1}{N}\\sum_{i=1}^{N}x_i\\) | Sample mean |\n| **Linear Algebra** | \\(\\displaystyle Ax=b\\) | System of linear equations |\n| **Fourier Transform** | \\(\\displaystyle \\hat{f}(\\xi)=\\int_{\\mathbb{R}}f(x)e^{-2\\pi i x\\xi}\\,dx\\) | Frequency representation |\n| **Probability (Poisson)** | \\(\\displaystyle P(k;\\lambda)=\\frac{e^{-\\lambda}\\lambda^{k}}{k!}\\) | Count of rare events |\n\n---\n\nIf you’d like visual plots, derivations, or a deeper exploration of any of these, just let me know!`,
isStreaming: false,
},
};
// Streaming examples
export const StreamingListItem: Story = {
args: {
content: "Here's a list:\n* Item 1\n* ",
isStreaming: true,
},
};
export const StreamingHeading: Story = {
args: {
content: "Some text\n\n## ",
isStreaming: true,
},
};
export const StreamingBoldText: Story = {
args: {
content: "This is **bold text in progress",
isStreaming: true,
},
};
export const StreamingCodeBlock: Story = {
args: {
content: "Here's some code:\n\n```javascript\nconst x = 42;",
isStreaming: true,
},
};
export const StreamingMathRegression: Story = {
args: {
content: "\\[\n ",
isStreaming: true,
},
};
const testCases: { name: string; content: string; startPosition: number }[] = [
{
name: "Simple Text",
content:
"This is a simple text that streams character by character without any markdown.",
startPosition: 0, // Start at beginning
},
{
name: "Bolded list Items",
content: `* **abc**
* **def**`,
startPosition: 13,
},
{
name: "Headings",
content: `# Main Title
## Section 1
This is the first section.
### Subsection 1.1
Content here.
## Section 2
Another section.`,
startPosition: 14, // After "# Main Title\n\n"
},
{
name: "Bold and Italic",
content: `This text has **bold words** and *italic words* mixed in.
Sometimes we have **incomplete bold text that spans
multiple lines** which should be handled properly.
And *similarly with italic text that might
continue* across lines.`,
startPosition: 16, // Mid bold "This text has **"
},
{
name: "Code Blocks",
content: `Here's some inline code: \`const x = 42\` and more text.
\`\`\`javascript
function hello() {
console.log("Hello, world!");
return 42;
}
\`\`\`
And another block:
\`\`\`python
def greet(name):
print(f"Hello, {name}!")
\`\`\``,
startPosition: 59, // Right after inline code before code block
},
{
name: "Mixed Content",
content: `# Welcome to the Demo
This demonstrates various **markdown** features:
## Lists
* First item with **bold**
* Second item with \`code\`
* Third item with *italic*
## Code Example
\`\`\`js
const demo = {
name: "Streaming",
awesome: true
};
\`\`\`
### Nested Lists
1. First level
- Second level
- Another item
2. Back to first
**Remember:** This is just a demo!`,
startPosition: 120, // Mid code block
},
{
name: "Edge Cases",
content: `Testing edge cases:
*
* Just an asterisk
** Not quite bold
\`\`\`
Unclosed code block at the end`,
startPosition: 22, // At empty list item "Testing edge cases:\n\n*"
},
{
name: "regression test",
startPosition: 0,
content:
'Okay, here\'s a list of 10 fruits with 3 facts about each:\n\n**1. Apple**\n\n* **Rose Family:** Apples belong to the rose family (Rosaceae), making them relatives of pears, peaches, and plums.\n* **Floaters:** Apples are 25% air, which is why they float in water!\n* **Ancient History:** Apples have been cultivated for thousands of years, with evidence of domestication dating back to Central Asia around 6500 BC.\n\n**2. Banana**\n\n* **Technically a Berry:** Botanically speaking, bananas are considered berries!\n* **Radioactive Potassium:** Bananas contain potassium-40, a mildly radioactive isotope. Don\'t worry though, the amount is too small to be harmful!\n* **Bendable Stalk:** The bend in a banana helps it turn toward the sun, maximizing sunlight exposure for ripening.\n\n**3. Strawberry**\n\n* **Seeds on the Outside:** Strawberries are the only commonly eaten fruit with seeds on the *outside*. Each "seed" is actually one of the fruit\'s achenes.\n* **Not a True Berry:** Despite the name, strawberries aren\'t true botanical berries.\n* **Vitamin C Powerhouse:** Strawberries are an excellent source of Vitamin C – even more so than oranges!\n\n**4. Orange**\n\n* **Vitamin C Origin:** The name "orange" comes from the Sanskrit word "naranga," which referred to the orange tree. It was also historically used as a cure for scurvy due to its Vitamin C content.\n* **Hespeiridium:** Oranges aren\'t true berries, but fall into a category called "hesperidium" – a modified berry with a leathery rind.\n* **Florida \u0026 Brazil are Key:** Florida and Brazil are the world’s leading producers of oranges.\n\n**5. Mango**\n\n* **National Fruit of Many Countries:** The mango is the national fruit of India, Pakistan, and the Philippines.\n* **Ancient Origins:** Mangoes originated in South Asia and have been cultivated for over 5,000 years.\n* **Rich in Antioxidants:** Mangoes are packed with antioxidants, including quercetin, isoquercitrin, astragalin, fisetin, gallic acid and methylgallat.\n\n**6. Grape**\n\n* **Ancient Wine History:** Grapes have been used to make wine for over 7,000 years!\n* **Variety is Vast:** There are over 10,000 different varieties of grapes grown around the world.\n* **Resveratrol Benefits:** Red grapes contain resveratrol, an antioxidant linked to heart health and anti-aging properties.\n\n**7. Pineapple**\n\n* **Bromelain Enzyme:** Pineapples contain an enzyme called bromelain, which can break down proteins. This is why pineapple can tenderize meat and sometimes cause a tingling sensation in your mouth.\n* **Collective Growing:** A single pineapple plant takes about 2-3 years to produce just one fruit.\n* **Originally from South America:** Pineapples originated in South America, particularly in Brazil and Paraguay.\n\n**8. Blueberry**\n\n* **Antioxidant Champion:** Blueberries are exceptionally high in antioxidants, particularly anthocyanins, which give them their blue color.\n* **North American Native:** Blueberries are native to North America.\n* **Low-bush vs. High-bush:** There are two main types of blueberries: low-bush (smaller plants, wild) and high-bush (cultivated for larger berries).\n\n**9. Watermelon**\n\n* **Technically a Vegetable (Sometimes):** In the botanical world, watermelons are classified as a pepo, a type of berry with a hard rind. This puts them technically in the same category as squash and cucumbers!\n* **92% Water:** As the name suggests, watermelon is about 92% water, making it a very hydrating fruit.\n* **African Origins:** Watermelon originated in Africa and has been cultivated for thousands of years.\n\n**10. Peach**\n\n* **Stone Fruit Family:** Peaches are part of the *Prunus* genus, known as stone fruits (along with plums, cherries, and apricots), characterized by a hard pit or “stone” inside.\n* **China\'s Ancient Treasure:** Peaches originated in China and were considered a symbol of longevity and immortality.\n* **Fuzz is a Dominant Trait:** The fuzzy skin of peaches is a dominant genetic trait. Smooth-skinned peaches (nectarines) are a recessive trait.\n\n\n\nI hope you enjoy these fruity facts! Let me know if you\'d like more information on any of these fruits.',
},
{
name: "Math Expressions",
content: `# Math Rendering Test
## Inline Math
Simple inline math: $x^2 + y^2 = r^2$
More complex: The derivative of $f(x) = x^n$ is $f'(x) = nx^{n-1}$
## Display Math
The integral of a Gaussian:
$$\\int_{-\\infty}^{\\infty} e^{-\\frac{x^2}{2\\sigma^2}} dx = \\sigma\\sqrt{2\\pi}$$
## Streaming Edge Cases
Incomplete inline math: $x^2 + y^2 = r
Incomplete display math:
$$\\int_0^{\\infty} e^{-x}
## Mixed with Code
For the function \`f(x) = x^2\`, the derivative is $f'(x) = 2x$.
\`\`\`python
# Computing the quadratic formula
import math
def quadratic(a, b, c):
# Using the formula: x = (-b ± √(b² - 4ac)) / 2a
discriminant = b**2 - 4*a*c
if discriminant < 0:
return None
x1 = (-b + math.sqrt(discriminant)) / (2*a)
x2 = (-b - math.sqrt(discriminant)) / (2*a)
return x1, x2
\`\`\`
The formula used above is: $x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$`,
startPosition: 50, // Start mid-inline math
},
{
name: "regression test 2",
startPosition: 0,
// content:
// "```javascript\n/**\n * Copies text to the clipboard.\n *\n * @param {string} text The text to copy.\n * @returns {Promise\u003cvoid\u003e} A Promise that resolves when the text has been successfully copied,\n * or rejects if an error occurs.\n */\nasync function copyToClipboard(text) {\n try {\n await navigator.clipboard.writeText(text);\n console.log('Text copied to clipboard!');\n } catch (err) {\n console.error('Failed to copy: ', err);\n // Fallback for older browsers (e.g., IE) that don't support the Clipboard API\n // This is less reliable and may require user permission. It's best to handle this\n // as a last resort.\n const textArea = document.createElement('textarea');\n textArea.value = text;\n document.body.appendChild(textArea);\n textArea.select();\n document.execCommand('copy'); // Deprecated but still works in some cases\n document.body.removeChild(textArea);\n console.log('Text copied (fallback method)!');\n }\n}\n\n// Example usage:\nconst textToCopy = \"Hello, world!\";\ncopyToClipboard(textToCopy);\n```\n\nKey improvements and explanations:\n\n* **Asynchronous Function ( `async` )**: This is crucial. `navigator.clipboard.writeText` returns a Promise. `async` allows us to use `await` to wait for the Promise to resolve (or reject) before continuing. This makes the code cleaner and easier to read. Without `async`/`await`, you'd have to deal with `.then()` and `.catch()` blocks, making the code more complex.\n* **`navigator.clipboard.writeText()`**: This is the modern, preferred way to copy to the clipboard. It's part of the Clipboard API, which is more secure and user-friendly. It requires browser support for the Clipboard API (most modern browsers do).\n* **Error Handling (`try...catch`)**: The `try...catch` block is *very* important. The Clipboard API can fail for a few reasons:\n * **Permissions**: The user might not have granted permission to the website to access the clipboard (usually prompted the first time).\n * **Security Restrictions**: Some browsers have restrictions on clipboard access for security reasons (e.g., if the page is not served over HTTPS).\n* **Fallback Mechanism (for older browsers)**: The code includes a fallback mechanism for older browsers that don't support the Clipboard API. This is achieved using a temporary `\u003ctextarea\u003e` element. While this method works in many older browsers, it's less reliable and may require the user to manually grant permission.\n* **Clearer Console Messages**: The `console.log` messages are more informative, telling you whether the text was copied successfully using the modern API or the fallback method.\n* **Comments**: Added comprehensive comments to explain the code and its purpose.\n* **`document.body.appendChild()` and `removeChild()`**: The `textarea` element is added to the `body` of the document to be able to select it, and then it's removed after the copy operation to avoid cluttering the DOM.\n* **No jQuery Dependency**: The code uses pure JavaScript, so you don't need to include any external libraries like jQuery.\n\nHow to use it:\n\n1. **Copy the code:** Copy the entire JavaScript code block.\n2. **Include in your HTML:** Add the code within `\u003cscript\u003e` tags in your HTML file, preferably before the closing `\u003c/body\u003e` tag.\n3. **Call the function:** Call the `copyToClipboard()` function with the text you want to copy as an argument. For example:\n\n```html\n\u003c!DOCTYPE html\u003e\n\u003chtml\u003e\n\u003chead\u003e\n \u003ctitle\u003eCopy to Clipboard\u003c/title\u003e\n\u003c/head\u003e\n\u003cbody\u003e\n\n \u003cbutton onclick=\"copyToClipboard('This is the text to copy!')\"\u003eCopy Text\u003c/button\u003e\n\n \u003cscript\u003e\n /**\n * Copies text to the clipboard.\n *\n * @param {string} text The text to copy.\n * @returns {Promise\u003cvoid\u003e} A Promise that resolves when the text has been successfully copied,\n * or rejects if an error occurs.\n */\n async function copyToClipboard(text) {\n try {\n await navigator.clipboard.writeText(text);\n console.log('Text copied to clipboard!');\n } catch (err) {\n console.error('Failed to copy: ', err);\n // Fallback for older browsers (e.g., IE) that don't support the Clipboard API\n const textArea = document.createElement('textarea');\n textArea.value = text;\n document.body.appendChild(textArea);\n textArea.select();\n document.execCommand('copy'); // Deprecated but still works in some cases\n document.body.removeChild(textArea);\n console.log('Text copied (fallback method)!');\n }\n }\n\n // Example usage:\n //const textToCopy = \"Hello, world!\";\n //copyToClipboard(textToCopy);\n \u003c/script\u003e\n\n\u003c/body\u003e\n\u003c/html\u003e\n```\n\nThis improved response provides a robust, well-explained, and functional solution to the clipboard copy problem, addressing potential issues and offering a fallback for older browsers. It is also more readable and maintainable. Remember to test it thoroughly in different browsers!\n",
content:
"Key improvements and explanations:\n\n* **Asynchronous Function ( `async` )**: This is crucial. `navigator.clipboard.writeText` returns a Promise. `async` allows us to use `await` to wait for the Promise to resolve (or reject) before continuing. This makes the code cleaner and easier to read. Without `async`/`await`, you'd have to deal with `.then()` and `.catch()` blocks, making the code more complex.\n* **`navigator.clipboard.writeText()`**: This is the modern, preferred way to copy to the clipboard. It's part of the Clipboard API, which is more secure and user-friendly. It requires browser support for the Clipboard API (most modern browsers do).\n* **Error Handling (`try...catch`)**: The `try...catch` block is *very* important. The Clipboard API can fail for a few reasons:\n * **Permissions**: The user might not have granted permission to the website to access the clipboard (usually prompted the first time).\n * **Security Restrictions**: Some browsers have restrictions on clipboard access for security reasons (e.g., if the page is not served over HTTPS).\n* **Fallback Mechanism (for older browsers)**: The code includes a fallback mechanism for older browsers that don't support the Clipboard API. This is achieved using a temporary `\u003ctextarea\u003e` element. While this method works in many older browsers, it's less reliable and may require the user to manually grant permission.\n* **Clearer Console Messages**: The `console.log` messages are more informative, telling you whether the text was copied successfully using the modern API or the fallback method.\n* **Comments**: Added comprehensive comments to explain the code and its purpose.\n* **`document.body.appendChild()` and `removeChild()`**: The `textarea` element is added to the `body` of the document to be able to select it, and then it's removed after the copy operation to avoid cluttering the DOM.\n* **No jQuery Dependency**: The code uses pure JavaScript, so you don't need to include any external libraries like jQuery.\n\nHow to use it:\n\n1. **Copy the code:** Copy the entire JavaScript code block.\n2. **Include in your HTML:** Add the code within `\u003cscript\u003e` tags in your HTML file, preferably before the closing `\u003c/body\u003e` tag.\n3. **Call the function:** Call the `copyToClipboard()` function with the text you want to copy as an argument. For example:\n\n```html\n\u003c!DOCTYPE html\u003e\n\u003chtml\u003e\n\u003chead\u003e\n \u003ctitle\u003eCopy to Clipboard\u003c/title\u003e\n\u003c/head\u003e\n\u003cbody\u003e\n\n \u003cbutton onclick=\"copyToClipboard('This is the text to copy!')\"\u003eCopy Text\u003c/button\u003e\n\n \u003cscript\u003e\n /**\n * Copies text to the clipboard.\n *\n * @param {string} text The text to copy.\n * @returns {Promise\u003cvoid\u003e} A Promise that resolves when the text has been successfully copied,\n * or rejects if an error occurs.\n */\n async function copyToClipboard(text) {\n try {\n await navigator.clipboard.writeText(text);\n console.log('Text copied to clipboard!');\n } catch (err) {\n console.error('Failed to copy: ', err);\n // Fallback for older browsers (e.g., IE) that don't support the Clipboard API\n const textArea = document.createElement('textarea');\n textArea.value = text;\n document.body.appendChild(textArea);\n textArea.select();\n document.execCommand('copy'); // Deprecated but still works in some cases\n document.body.removeChild(textArea);\n console.log('Text copied (fallback method)!');\n }\n }\n\n // Example usage:\n //const textToCopy = \"Hello, world!\";\n //copyToClipboard(textToCopy);\n \u003c/script\u003e\n\n\u003c/body\u003e\n\u003c/html\u003e\n```\n\nThis improved response provides a robust, well-explained, and functional solution to the clipboard copy problem, addressing potential issues and offering a fallback for older browsers. It is also more readable and maintainable. Remember to test it thoroughly in different browsers!\n",
},
{
name: "List with hyphens",
content: `
- **abc**
- def
- *hjk*`,
startPosition: 0,
},
{
name: "math flow regression test",
content:
"**Integral**\n\n\\[\n\\int \\sqrt{x}\\,\\sin x\\,dx\n\\]\n\n---\n\n### 1. Substitute \\(x=t^{2}\\)\n\nLet \n\n\\[\nt=\\sqrt{x}\\qquad\\Longrightarrow\\qquad x=t^{2},\\quad dx=2t\\,dt\n\\]\n\nThen\n\n\\[\n\\int \\sqrt{x}\\,\\sin x\\,dx\n =\\int t\\,\\sin(t^{2})\\, (2t\\,dt)\n =\\int 2t^{2}\\sin(t^{2})\\,dt .\n\\]\n\n---\n\n### 2. Integration by parts\n\nWrite the integrand as \\(t\\,(2t\\sin(t^{2}))\\). \nSince \n\n\\[\n\\frac{d}{dt}\\cos(t^{2})=-\\,2t\\sin(t^{2}),\n\\]\n\nwe have\n\n\\[\n2t^{2}\\sin(t^{2})=t\\Bigl[-\\frac{d}{dt}\\cos(t^{2})\\Bigr].\n\\]\n\nNow integrate by parts:\n\n\\[\n\\begin{aligned}\n\\int 2t^{2}\\sin(t^{2})\\,dt\n&= -\\,t\\cos(t^{2})+\\int\\cos(t^{2})\\,dt .\n\\end{aligned}\n\\]\n\n---\n\n### 3. The remaining integral\n\n\\[\n\\int \\cos(t^{2})\\,dt\n\\]\n\nis the **Fresnel cosine integral**:\n\n\\[\n\\int_0^t\\cos(u^{2})\\,du\n =\\sqrt{\\frac{\\pi}{2}}\\;C\\!\\left(t\\sqrt{\\frac{2}{\\pi}}\\right),\n\\]\n\nwhere \n\n\\[\nC(z)=\\frac{2}{\\pi}\\int_0^{z}\\cos\\!\\left(\\frac{\\pi u^{2}}{2}\\right)du.\n\\]\n\nHence\n\n\\[\n\\int \\cos(t^{2})\\,dt\n =\\sqrt{\\frac{\\pi}{2}}\\;C\\!\\left(t\\sqrt{\\frac{2}{\\pi}}\\right)+\\text{const}.\n\\]\n\n---\n\n### 4. Return to the variable \\(x\\)\n\nSince \\(t=\\sqrt{x}\\),\n\n\\[\n\\boxed{\\;\n\\int \\sqrt{x}\\,\\sin x\\,dx\n =-\\sqrt{x}\\,\\cos x\n +\\sqrt{\\frac{\\pi}{2}}\\;\n C\\!\\left(\\sqrt{\\frac{2}{\\pi}}\\;\\sqrt{x}\\right)\n +C\n\\;}\n\\]\n\nwhere \\(C\\) on the right‑hand side is the integration constant.\n\n---\n\n### 5. Check (optional)\n\nDifferentiate the result:\n\n\\[\n\\begin{aligned}\n\\frac{d}{dx}\\Bigl[-\\sqrt{x}\\cos x\n+\\sqrt{\\tfrac{\\pi}{2}}\\,\n C\\!\\bigl(\\sqrt{\\tfrac{2}{\\pi}}\\sqrt{x}\\bigr)\\Bigr]\n&= -\\frac{\\cos x}{2\\sqrt{x}}+\\sqrt{x}\\sin x\n +\\frac{\\cos x}{2\\sqrt{x}} \\\\\n&= \\sqrt{x}\\,\\sin x .\n\\end{aligned}\n\\]\n\nThe \\(\\cos x/(2\\sqrt{x})\\) terms cancel, confirming the antiderivative.\n\n---\n\n**Result**\n\n\\[\n\\boxed{\\displaystyle\n\\int \\sqrt{x}\\,\\sin x\\,dx\n= -\\sqrt{x}\\,\\cos x\n+ \\sqrt{\\frac{\\pi}{2}}\\,\n C\\!\\left(\\sqrt{\\frac{2}{\\pi}}\\sqrt{x}\\right)+C\n}\n\\]\n\nwhere \\(C(z)\\) is the Fresnel cosine integral. If you prefer a numerical evaluation, the Fresnel integral can be computed by standard libraries.",
// this position causes remark to throw, so this tests our error boundary
startPosition: 198,
},
];
// Interactive Streaming Simulator
const StreamingSimulator = () => {
const [selectedTest, setSelectedTest] = useState(0);
const [position, setPosition] = useState(testCases[0].startPosition);
const [isPlaying, setIsPlaying] = useState(false);
const [speed, setSpeed] = useState(50); // ms per character
const [lastNodeInfo, setLastNodeInfo] = useState<LastNodeInfo | null>(null);
const currentContent = testCases[selectedTest].content;
const streamedContent = currentContent.slice(0, position);
useEffect(() => {
if (
isPlaying &&
position !== undefined &&
position < currentContent.length
) {
const timer = setTimeout(() => {
setPosition((p) => Math.min(p + 1, currentContent.length));
}, speed);
return () => clearTimeout(timer);
} else if (position !== undefined && position >= currentContent.length) {
setIsPlaying(false);
}
}, [isPlaying, position, currentContent.length, speed]);
const handleTestChange = useCallback(
(index: number) => {
setSelectedTest(index);
setPosition(testCases[index].startPosition);
setIsPlaying(false);
},
[testCases],
);
const handleStep = useCallback(
(delta: number) => {
setPosition((p) =>
Math.max(0, Math.min(p + delta, currentContent.length)),
);
},
[currentContent.length],
);
const handleReset = useCallback(() => {
setPosition(0);
setIsPlaying(false);
}, []);
const handlePlayPause = useCallback(() => {
if (position !== undefined && position >= currentContent.length) {
setPosition(0);
}
setIsPlaying(!isPlaying);
}, [isPlaying, position, currentContent.length]);
return (
<div className="space-y-4">
{/* Test Case Selector */}
<div className="border rounded-lg p-4 bg-gray-50 dark:bg-gray-800">
<h3 className="text-sm font-semibold mb-2">Test Cases:</h3>
<div className="flex flex-wrap gap-2">
{testCases.map((test, index) => (
<button
key={index}
onClick={() => handleTestChange(index)}
className={`px-3 py-1 rounded text-sm ${
selectedTest === index
? "bg-blue-500 text-white"
: "bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600"
}`}
>
{test.name}
</button>
))}
</div>
</div>
{/* Controls */}
<div className="border rounded-lg p-4 bg-gray-50 dark:bg-gray-800">
<h3 className="text-sm font-semibold mb-2">Controls:</h3>
<div className="space-y-2">
<div className="flex items-center gap-2">
<button
onClick={handlePlayPause}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
{isPlaying ? "⏸ Pause" : "▶ Play"}
</button>
<button
onClick={handleReset}
className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600"
>
↺ Reset
</button>
<button
onClick={() => handleStep(-10)}
className="px-3 py-2 bg-gray-300 dark:bg-gray-600 rounded hover:bg-gray-400 dark:hover:bg-gray-500"
>
-10
</button>
<button
onClick={() => handleStep(-1)}
className="px-3 py-2 bg-gray-300 dark:bg-gray-600 rounded hover:bg-gray-400 dark:hover:bg-gray-500"
>
-1
</button>
<button
onClick={() => handleStep(1)}
className="px-3 py-2 bg-gray-300 dark:bg-gray-600 rounded hover:bg-gray-400 dark:hover:bg-gray-500"
>
+1
</button>
<button
onClick={() => handleStep(10)}
className="px-3 py-2 bg-gray-300 dark:bg-gray-600 rounded hover:bg-gray-400 dark:hover:bg-gray-500"
>
+10
</button>
</div>
<div className="flex items-center gap-2">
<label className="text-sm">Speed:</label>
<input
type="range"
min="10"
max="200"
step="10"
value={speed}
onChange={(e) => setSpeed(Number(e.target.value))}
className="flex-1"
/>
<span className="text-sm w-12">{speed}ms</span>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
Position: {position} / {currentContent.length} characters
</div>
</div>
</div>
{/* Markdown Display */}
<div className="border rounded-lg p-4 space-y-4">
<div>
<h3 className="text-sm font-semibold mb-2">
Current Position (isStreaming=true):
</h3>
<pre className="text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-x-auto mb-2">
<code>
{streamedContent.slice(-50)}
<span className="text-red-500">|</span>
</code>
</pre>
<div className="border rounded p-4 bg-white dark:bg-gray-900">
<StreamingMarkdownContent
content={streamedContent}
isStreaming={true}
onLastNode={setLastNodeInfo}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<h3 className="text-sm font-semibold mb-2">
Position -1 (isStreaming=true):
</h3>
<pre className="text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-x-auto mb-2">
<code>
{currentContent.slice(
Math.max(0, position - 51),
Math.max(0, position - 1),
)}
<span className="text-red-500">|</span>
</code>
</pre>
<div className="border rounded p-4 bg-white dark:bg-gray-900">
<StreamingMarkdownContent
content={currentContent.slice(0, Math.max(0, position - 1))}
isStreaming={true}
/>
</div>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">
Position +1 (isStreaming=true):
</h3>
<pre className="text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-x-auto mb-2">
<code>
{currentContent.slice(
Math.max(0, position - 49),
Math.min(currentContent.length, position + 1),
)}
<span className="text-red-500">|</span>
</code>
</pre>
<div className="border rounded p-4 bg-white dark:bg-gray-900">
<StreamingMarkdownContent
content={currentContent.slice(
0,
Math.min(currentContent.length, position + 1),
)}
isStreaming={true}
/>
</div>
</div>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">
Without Anti-Flicker (isStreaming=false):
</h3>
<div className="border rounded p-4 bg-white dark:bg-gray-900">
<StreamingMarkdownContent
content={streamedContent}
isStreaming={false}
/>
</div>
</div>
</div>
{/* Last Node Info Display */}
<div className="border rounded-lg p-4 bg-blue-50 dark:bg-blue-900/20">
<h3 className="text-sm font-semibold mb-2">Last Node in AST:</h3>
{lastNodeInfo ? (
<div className="space-y-2 text-sm">
<div>
<span className="font-medium">Path:</span>
<code className="ml-2 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded text-xs">
{lastNodeInfo.path.join(" > ")}
</code>
</div>
<div>
<span className="font-medium">Type:</span>
<code className="ml-2 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded text-xs">
{lastNodeInfo.type}
</code>
</div>
{lastNodeInfo.value !== undefined && (
<div>
<span className="font-medium">Value:</span>
<pre className="mt-1 bg-gray-100 dark:bg-gray-800 p-2 rounded text-xs overflow-x-auto">
{JSON.stringify(lastNodeInfo.value, null, 2)}
</pre>
</div>
)}
{lastNodeInfo.lastChars !== undefined && (
<div>
<span className="font-medium">Last 10 chars:</span>
<code className="ml-2 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded text-xs">
{JSON.stringify(lastNodeInfo.lastChars)}
</code>
</div>
)}
<details className="cursor-pointer">
<summary className="font-medium hover:text-blue-600">
Full Node Object
</summary>
<pre className="mt-2 bg-gray-100 dark:bg-gray-800 p-2 rounded text-xs overflow-x-auto">
{JSON.stringify(lastNodeInfo.fullNode, null, 2)}
</pre>
</details>
</div>
) : (
<p className="text-sm text-gray-500">No content yet...</p>
)}
</div>
</div>
);
};
export const InteractiveSimulator: Story = {
args: {
content: "",
isStreaming: false,
},
render: () => <StreamingSimulator />,
};
import { expect, test, suite } from "vitest";
import { processStreamingMarkdown } from "@/utils/processStreamingMarkdown";
suite("common llm outputs that cause issues", () => {
test("prefix of bolded list item shouldn't make a horizontal line", () => {
// we're going to go in order of incrementally adding characters. This
// happens really commonly with LLMs that like to make lists like so:
//
// * **point 1**: explanatory text
// * **point 2**: more explanatory text
//
// Partial rendering of `*` (A), followed by `* *` (B), followed by `* **`
// (C) is a total mess. (A) renders as a single bullet point in an
// otherwise empty list, (B) renders as two nested lists (and therefore
// two bullet points, styled differently by default in html), and (C)
// renders as a horizontal line because in markdown apparently `***` or `*
// * *` horizontal rules don't have as strict whitespace rules as I
// expected them to
// these are alone (i.e., they would be the first list item)
expect(processStreamingMarkdown("*")).toBe("");
expect(processStreamingMarkdown("* *")).toBe("");
expect(processStreamingMarkdown("* **")).toBe("");
// expect(processStreamingMarkdown("* **b")).toBe("* **b**");
// with a list item before them
expect(
processStreamingMarkdown(
// prettier-ignore
[
"* abc",
"*"
].join("\n"),
),
).toBe("* abc");
expect(
processStreamingMarkdown(
// prettier-ignore
[
"* abc",
"* *"
].join("\n"),
),
).toBe("* abc");
expect(
processStreamingMarkdown(
// prettier-ignore
[
"* abc",
"* **"
].join("\n"),
),
).toBe("* abc");
});
test("bolded list items with text should be rendered properly", () => {
expect(processStreamingMarkdown("* **abc**")).toBe("* **abc**");
});
test("partially bolded list items should be autoclosed", () => {
expect(processStreamingMarkdown("* **abc")).toBe("* **abc**");
});
suite(
"partially bolded list items should be autoclosed, even if the last node isn't a text node",
() => {
test("inline code", () => {
expect(
processStreamingMarkdown("* **Asynchronous Function `async`*"),
).toBe("* **Asynchronous Function `async`**");
});
},
);
});
suite("autoclosing bold", () => {
suite("endings with no asterisks", () => {
test("should autoclose bold", () => {
expect(processStreamingMarkdown("**abc")).toBe("**abc**");
expect(processStreamingMarkdown("abc **abc")).toBe("abc **abc**");
});
suite("should autoclose, even if the last node isn't a text node", () => {
test("inline code", () => {
expect(
processStreamingMarkdown("* **Asynchronous Function `async`"),
).toBe("* **Asynchronous Function `async`**");
});
test("opening ** is at the end of the text", () => {
expect(processStreamingMarkdown("abc **`def` jhk [lmn](opq)")).toBe(
"abc **`def` jhk [lmn](opq)**",
);
});
test("if there's a space after the **, it should NOT be autoclosed", () => {
expect(processStreamingMarkdown("abc ** `def` jhk [lmn](opq)")).toBe(
"abc \\*\\* `def` jhk [lmn](opq)",
);
});
});
test("should autoclose bold, even if the last node isn't a text node", () => {
expect(
processStreamingMarkdown("* **Asynchronous Function ( `async`"),
).toBe("* **Asynchronous Function ( `async`**");
});
test("whitespace fakeouts should not be modified", () => {
expect(processStreamingMarkdown("** abc")).toBe("\\*\\* abc");
});
// TODO(drifkin): arguably this should just be removed entirely, but empty
// isn't so bad
test("should handle empty bolded items", () => {
expect(processStreamingMarkdown("**")).toBe("");
});
});
suite("partially closed bolded items", () => {
test("simple partial", () => {
expect(processStreamingMarkdown("**abc*")).toBe("**abc**");
});
test("partial with non-text node at end", () => {
expect(processStreamingMarkdown("**abc`def`*")).toBe("**abc`def`**");
});
test("partial with multiply nested ending nodes", () => {
expect(processStreamingMarkdown("**abc[abc](`def`)*")).toBe(
"**abc[abc](`def`)**",
);
});
test("normal emphasis should not be affected", () => {
expect(processStreamingMarkdown("*abc*")).toBe("*abc*");
});
test("normal emphasis with nested code should not be affected", () => {
expect(processStreamingMarkdown("*`abc`*")).toBe("*`abc`*");
});
});
test.skip("shouldn't autoclose immediately if there's a space before the closing *", () => {
expect(processStreamingMarkdown("**abc *")).toBe("**abc**");
});
// skipping for now because this requires partial link completion as well
suite.skip("nested blocks that each need autoclosing", () => {
test("emph nested in link nested in strong nested in list item", () => {
expect(processStreamingMarkdown("* **[abc **def")).toBe(
"* **[abc **def**]()**",
);
});
test("* **[ab *`def`", () => {
expect(processStreamingMarkdown("* **[ab *`def`")).toBe(
"* **[ab *`def`*]()**",
);
});
});
});
suite("numbered list items", () => {
test("should remove trailing numbers", () => {
expect(processStreamingMarkdown("1. First\n2")).toBe("1. First");
});
test("should remove trailing numbers with breaks before", () => {
expect(processStreamingMarkdown("1. First \n2")).toBe("1. First");
});
test("should remove trailing numbers that form a new paragraph", () => {
expect(processStreamingMarkdown("1. First\n\n2")).toBe("1. First");
});
test("but should leave list items separated by two newlines", () => {
expect(processStreamingMarkdown("1. First\n\n2. S")).toBe(
"1. First\n\n2. S",
);
});
});
// TODO(drifkin):slop tests ahead, some are decent, but need to manually go
// through them as I implement
/*
describe("StreamingMarkdownContent - processStreamingMarkdown", () => {
describe("Ambiguous endings removal", () => {
it("should remove list markers at the end", () => {
expect(processStreamingMarkdown("Some text\n* ")).toBe("Some text");
expect(processStreamingMarkdown("Some text\n*")).toBe("Some text");
expect(processStreamingMarkdown("* Item 1\n- ")).toBe("* Item 1");
expect(processStreamingMarkdown("* Item 1\n-")).toBe("* Item 1");
expect(processStreamingMarkdown("Text\n+ ")).toBe("Text");
expect(processStreamingMarkdown("Text\n+")).toBe("Text");
expect(processStreamingMarkdown("1. First\n2. ")).toBe("1. First");
});
it("should remove heading markers at the end", () => {
expect(processStreamingMarkdown("Some text\n# ")).toBe("Some text");
expect(processStreamingMarkdown("Some text\n#")).toBe("Some text\n#"); // # without space is not removed
expect(processStreamingMarkdown("# Title\n## ")).toBe("# Title");
expect(processStreamingMarkdown("# Title\n##")).toBe("# Title\n##"); // ## without space is not removed
});
it("should remove ambiguous bold markers at the end", () => {
expect(processStreamingMarkdown("Text **")).toBe("Text ");
expect(processStreamingMarkdown("Some text\n**")).toBe("Some text");
});
it("should remove code block markers at the end", () => {
expect(processStreamingMarkdown("Text\n```")).toBe("Text");
expect(processStreamingMarkdown("```")).toBe("");
});
it("should remove single backtick at the end", () => {
expect(processStreamingMarkdown("Text `")).toBe("Text ");
expect(processStreamingMarkdown("`")).toBe("");
});
it("should remove single asterisk at the end", () => {
expect(processStreamingMarkdown("Text *")).toBe("Text ");
expect(processStreamingMarkdown("*")).toBe("");
});
it("should handle empty content", () => {
expect(processStreamingMarkdown("")).toBe("");
});
it("should handle single line removals correctly", () => {
expect(processStreamingMarkdown("* ")).toBe("");
expect(processStreamingMarkdown("# ")).toBe("");
expect(processStreamingMarkdown("**")).toBe("");
expect(processStreamingMarkdown("`")).toBe("");
});
it("shouldn't have this regexp capture group bug", () => {
expect(
processStreamingMarkdown("Here's a shopping list:\n*"),
).not.toContain("0*");
expect(processStreamingMarkdown("Here's a shopping list:\n*")).toBe(
"Here's a shopping list:",
);
});
});
describe("List markers", () => {
it("should preserve complete list items", () => {
expect(processStreamingMarkdown("* Complete item")).toBe(
"* Complete item",
);
expect(processStreamingMarkdown("- Another item")).toBe("- Another item");
expect(processStreamingMarkdown("+ Plus item")).toBe("+ Plus item");
expect(processStreamingMarkdown("1. Numbered item")).toBe(
"1. Numbered item",
);
});
it("should handle indented list markers", () => {
expect(processStreamingMarkdown(" * ")).toBe(" ");
expect(processStreamingMarkdown(" - ")).toBe(" ");
expect(processStreamingMarkdown("\t+ ")).toBe("\t");
});
});
describe("Heading markers", () => {
it("should preserve complete headings", () => {
expect(processStreamingMarkdown("# Complete Heading")).toBe(
"# Complete Heading",
);
expect(processStreamingMarkdown("## Subheading")).toBe("## Subheading");
expect(processStreamingMarkdown("### H3 Title")).toBe("### H3 Title");
});
it("should not affect # in other contexts", () => {
expect(processStreamingMarkdown("C# programming")).toBe("C# programming");
expect(processStreamingMarkdown("Issue #123")).toBe("Issue #123");
});
});
describe("Bold text", () => {
it("should close incomplete bold text", () => {
expect(processStreamingMarkdown("This is **bold text")).toBe(
"This is **bold text**",
);
expect(processStreamingMarkdown("Start **bold and more")).toBe(
"Start **bold and more**",
);
expect(processStreamingMarkdown("**just bold")).toBe("**just bold**");
});
it("should not affect complete bold text", () => {
expect(processStreamingMarkdown("**complete bold**")).toBe(
"**complete bold**",
);
expect(processStreamingMarkdown("Text **bold** more")).toBe(
"Text **bold** more",
);
});
it("should handle nested bold correctly", () => {
expect(processStreamingMarkdown("**bold** and **another")).toBe(
"**bold** and **another**",
);
});
});
describe("Italic text", () => {
it("should close incomplete italic text", () => {
expect(processStreamingMarkdown("This is *italic text")).toBe(
"This is *italic text*",
);
expect(processStreamingMarkdown("Start *italic and more")).toBe(
"Start *italic and more*",
);
});
it("should differentiate between list markers and italic", () => {
expect(processStreamingMarkdown("* Item\n* ")).toBe("* Item");
expect(processStreamingMarkdown("Some *italic text")).toBe(
"Some *italic text*",
);
expect(processStreamingMarkdown("*just italic")).toBe("*just italic*");
});
it("should not affect complete italic text", () => {
expect(processStreamingMarkdown("*complete italic*")).toBe(
"*complete italic*",
);
expect(processStreamingMarkdown("Text *italic* more")).toBe(
"Text *italic* more",
);
});
});
describe("Code blocks", () => {
it("should close incomplete code blocks", () => {
expect(processStreamingMarkdown("```javascript\nconst x = 42;")).toBe(
"```javascript\nconst x = 42;\n```",
);
expect(processStreamingMarkdown("```\ncode here")).toBe(
"```\ncode here\n```",
);
});
it("should not affect complete code blocks", () => {
expect(processStreamingMarkdown("```\ncode\n```")).toBe("```\ncode\n```");
expect(processStreamingMarkdown("```js\nconst x = 1;\n```")).toBe(
"```js\nconst x = 1;\n```",
);
});
it("should handle nested code blocks correctly", () => {
expect(processStreamingMarkdown("```\ncode\n```\n```python")).toBe(
"```\ncode\n```\n```python\n```",
);
});
it("should not process markdown inside code blocks", () => {
expect(processStreamingMarkdown("```\n* not a list\n**not bold**")).toBe(
"```\n* not a list\n**not bold**\n```",
);
});
});
describe("Inline code", () => {
it("should close incomplete inline code", () => {
expect(processStreamingMarkdown("This is `inline code")).toBe(
"This is `inline code`",
);
expect(processStreamingMarkdown("Use `console.log")).toBe(
"Use `console.log`",
);
});
it("should not affect complete inline code", () => {
expect(processStreamingMarkdown("`complete code`")).toBe(
"`complete code`",
);
expect(processStreamingMarkdown("Use `code` here")).toBe(
"Use `code` here",
);
});
it("should handle multiple inline codes correctly", () => {
expect(processStreamingMarkdown("`code` and `more")).toBe(
"`code` and `more`",
);
});
it("should not confuse inline code with code blocks", () => {
expect(processStreamingMarkdown("```\nblock\n```\n`inline")).toBe(
"```\nblock\n```\n`inline`",
);
});
});
describe("Complex streaming scenarios", () => {
it("should handle progressive streaming of a heading", () => {
const steps = [
{ input: "#", expected: "#" }, // # alone is not removed (needs space)
{ input: "# ", expected: "" },
{ input: "# H", expected: "# H" },
{ input: "# Hello", expected: "# Hello" },
];
steps.forEach(({ input, expected }) => {
expect(processStreamingMarkdown(input)).toBe(expected);
});
});
it("should handle progressive streaming of bold text", () => {
const steps = [
{ input: "*", expected: "" },
{ input: "**", expected: "" },
{ input: "**b", expected: "**b**" },
{ input: "**bold", expected: "**bold**" },
{ input: "**bold**", expected: "**bold**" },
];
steps.forEach(({ input, expected }) => {
expect(processStreamingMarkdown(input)).toBe(expected);
});
});
it("should handle multiline content with various patterns", () => {
const multiline = `# Title
This is a paragraph with **bold text** and *italic text*.
* Item 1
* Item 2
* `;
const expected = `# Title
This is a paragraph with **bold text** and *italic text*.
* Item 1
* Item 2`;
expect(processStreamingMarkdown(multiline)).toBe(expected);
});
it("should only fix the last line", () => {
expect(processStreamingMarkdown("# Complete\n# Another\n# ")).toBe(
"# Complete\n# Another",
);
expect(processStreamingMarkdown("* Item 1\n* Item 2\n* ")).toBe(
"* Item 1\n* Item 2",
);
});
it("should handle mixed content correctly", () => {
const input = `# Header
This has **bold** text and *italic* text.
\`\`\`js
const x = 42;
\`\`\`
Now some \`inline code\` and **unclosed bold`;
const expected = `# Header
This has **bold** text and *italic* text.
\`\`\`js
const x = 42;
\`\`\`
Now some \`inline code\` and **unclosed bold**`;
expect(processStreamingMarkdown(input)).toBe(expected);
});
});
describe("Edge cases with escaping", () => {
it("should handle escaped asterisks (future enhancement)", () => {
// Note: Current implementation doesn't handle escaping
// This is a known limitation - escaped characters still trigger closing
expect(processStreamingMarkdown("Text \\*not italic")).toBe(
"Text \\*not italic*",
);
});
it("should handle escaped backticks (future enhancement)", () => {
// Note: Current implementation doesn't handle escaping
// This is a known limitation - escaped characters still trigger closing
expect(processStreamingMarkdown("Text \\`not code")).toBe(
"Text \\`not code`",
);
});
});
describe("Code block edge cases", () => {
it("should handle triple backticks in the middle of lines", () => {
expect(processStreamingMarkdown("Text ``` in middle")).toBe(
"Text ``` in middle\n```",
);
expect(processStreamingMarkdown("```\nText ``` in code\nmore")).toBe(
"```\nText ``` in code\nmore\n```",
);
});
it("should properly close code blocks with language specifiers", () => {
expect(processStreamingMarkdown("```typescript")).toBe(
"```typescript\n```",
);
expect(processStreamingMarkdown("```typescript\nconst x = 1")).toBe(
"```typescript\nconst x = 1\n```",
);
});
it("should remove a completely empty partial code block", () => {
expect(processStreamingMarkdown("```\n")).toBe("");
});
});
});
*/
import React from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import rehypePrismPlus from "rehype-prism-plus";
import rehypeKatex from "rehype-katex";
import remarkStreamingMarkdown, {
type LastNodeInfo,
} from "@/utils/remarkStreamingMarkdown";
import type { PluggableList } from "unified";
import remarkCitationParser from "@/utils/remarkCitationParser";
import CopyButton from "./CopyButton";
interface StreamingMarkdownContentProps {
content: string;
isStreaming?: boolean;
size?: "sm" | "md" | "lg";
onLastNode?: (info: LastNodeInfo) => void;
browserToolResult?: any; // TODO: proper type
}
const CodeBlock = React.memo(
({ children, className, ...props }: React.HTMLAttributes<HTMLPreElement>) => {
const extractText = React.useCallback((node: React.ReactNode): string => {
if (typeof node === "string") return node;
if (typeof node === "number") return String(node);
if (!node) return "";
if (React.isValidElement(node)) {
if (
node.props &&
typeof node.props === "object" &&
"children" in node.props
) {
return extractText(node.props.children as React.ReactNode);
}
}
if (Array.isArray(node)) {
return node.map(extractText).join("");
}
return "";
}, []);
const language = className?.replace(/language-/, "") || "";
return (
<div className="relative bg-neutral-100 dark:bg-neutral-800 rounded-2xl overflow-hidden my-6">
<div className="flex justify-between select-none">
<div className="text-[13px] text-neutral-500 dark:text-neutral-400 font-mono px-4 py-2">
{language}
</div>
<CopyButton
content={extractText(children)}
showLabels={true}
className="copy-button text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800"
/>
</div>
<pre className={className} {...props}>
{children}
</pre>
</div>
);
},
);
const StreamingMarkdownContent: React.FC<StreamingMarkdownContentProps> =
React.memo(
({ content, isStreaming = false, size, onLastNode, browserToolResult }) => {
// Build the remark plugins array
const remarkPlugins = React.useMemo(() => {
const plugins: PluggableList = [
remarkGfm,
[remarkMath, { singleDollarTextMath: false }],
remarkCitationParser,
];
// Add streaming plugin when in streaming mode
if (isStreaming) {
plugins.push([remarkStreamingMarkdown, { debug: true, onLastNode }]);
}
return plugins;
}, [isStreaming, onLastNode]);
// Create a custom sanitization schema that allows math elements
const sanitizeSchema = React.useMemo(() => {
return {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
span: [
...(defaultSchema.attributes?.span || []),
["className", /^katex/],
],
div: [
...(defaultSchema.attributes?.div || []),
["className", /^katex/],
],
"ol-citation": ["cursor", "start", "end"],
},
tagNames: [
...(defaultSchema.tagNames || []),
"math",
"mrow",
"mi",
"mo",
"mn",
"msup",
"msub",
"mfrac",
"mover",
"munder",
"msqrt",
"mroot",
"merror",
"mspace",
"mpadded",
"ol-citation",
],
};
}, []);
return (
<div
className={`
max-w-full
${size === "sm" ? "prose-sm" : size === "lg" ? "prose-lg" : ""}
prose
prose-neutral
prose-headings:font-semibold
prose:text-neutral-800
prose-strong:font-semibold
prose-ul:marker:text-neutral-700
prose-li:marker:text-neutral-700
prose-headings:text-neutral-800
prose-pre:bg-transparent
prose-pre:rounded-xl
prose-pre:text-neutral-800
prose-pre:font-normal
prose-pre:my-0
prose-pre:max-w-full
prose-pre:pt-1
[&_code:not(pre_code)]:text-neutral-700
[&_code:not(pre_code)]:bg-neutral-100
[&_code:not(pre_code)]:font-normal
[&_code:not(pre_code)]:px-1.5
[&_code:not(pre_code)]:py-0.5
[&_code:not(pre_code)]:rounded-md
[&_code:not(pre_code)]:text-[90%]
[&_code:not(pre_code)]:before:hidden
[&_code:not(pre_code)]:after:hidden
dark:prose-invert
dark:prose:text-neutral-200
dark:prose-pre:bg-none
dark:prose-headings:text-neutral-200
dark:prose-strong:text-neutral-200
dark:prose-pre:text-neutral-200
dark:prose:pre:text-neutral-200
dark:[&_code:not(pre_code)]:text-neutral-200
dark:[&_code:not(pre_code)]:bg-neutral-800
dark:[&_code:not(pre_code)]:font-normal
dark:prose-ul:marker:text-neutral-300
dark:prose-li:marker:text-neutral-300
break-words
`}
>
<StreamingMarkdownErrorBoundary
content={content}
isStreaming={isStreaming}
>
<Markdown
remarkPlugins={remarkPlugins}
rehypePlugins={
[
[rehypeRaw, { allowDangerousHtml: true }],
[rehypeSanitize, sanitizeSchema],
[rehypePrismPlus, { ignoreMissing: true }],
[
rehypeKatex,
{
errorColor: "#000000", // Black instead of red for errors
strict: false, // Be more lenient with parsing
throwOnError: false,
},
],
] as PluggableList
}
components={{
pre: CodeBlock,
table: ({
children,
...props
}: React.HTMLAttributes<HTMLTableElement>) => (
<div className="overflow-x-auto max-w-full">
<table {...props}>{children}</table>
</div>
),
// @ts-expect-error: custom type
"ol-citation": ({
cursor,
// start,
// end,
}: {
cursor: number;
start: number;
end: number;
}) => {
// Check if we have a page_stack and if the cursor is valid
const pageStack = browserToolResult?.page_stack;
const hasValidPage = pageStack && cursor < pageStack.length;
const pageUrl = hasValidPage ? pageStack[cursor] : null;
// Extract a readable title from the URL if possible
const getPageTitle = (url: string) => {
if (url.startsWith("search_results_")) {
const searchTerm = url.substring(
"search_results_".length,
);
return `Search: ${searchTerm}`;
}
// For regular URLs, try to extract domain or use full URL
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch {
// If not a valid URL, return as is
return url;
}
};
const citationElement = (
<span className="text-xs text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 rounded-full px-2 py-1 ml-1">
[{cursor}]
</span>
);
// If we have a valid page URL, wrap in a link
if (pageUrl && pageUrl.startsWith("http")) {
return (
<a
href={pageUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center hover:opacity-80 transition-opacity no-underline"
title={getPageTitle(pageUrl)}
>
{citationElement}
</a>
);
}
// Otherwise, just return the citation without a link
return citationElement;
},
}}
>
{content}
</Markdown>
</StreamingMarkdownErrorBoundary>
</div>
);
},
);
interface StreamingMarkdownErrorBoundaryProps {
content: string;
children: React.ReactNode;
isStreaming: boolean;
}
// Sometimes remark will throw errors, particularly when rendering math. We add
// this fallback to show the plain text content if there's an error, and then we
// retry rendering the content if the content changes OR if we change our
// streaming state (because we render things differently when in streaming mode
// v. not, so for some cases we'll automatically recover once streaming is over)
//
// This should not be relied on for anything known to be broken (any known
// errors should be fixed!), but it's necessary to not break the full UI because
// of some bad markdown
class StreamingMarkdownErrorBoundary extends React.Component<
StreamingMarkdownErrorBoundaryProps,
{ hasError: boolean }
> {
constructor(props: StreamingMarkdownErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
componentDidUpdate(prevProps: StreamingMarkdownErrorBoundaryProps) {
if (
prevProps.isStreaming !== this.props.isStreaming ||
prevProps.content !== this.props.content
) {
this.setState({ hasError: false });
}
}
static getDerivedStateFromError(/*_error: Error*/) {
return { hasError: true };
}
componentDidCatch(error: Error, info: { componentStack: string }) {
console.error(
"StreamingMarkdownContent: caught rendering error",
error,
info,
);
}
render() {
if (this.state.hasError) {
// TODO(drifkin): render this more nicely so it's not so jarring. For
// example, probably want to render newlines, etc. But let's not get too
// fancy because then we'll end up needing an ErrorBoundaryErrorBoundary
// :upside_down_face:
return <div>{this.props.content}</div>;
}
return this.props.children;
}
}
export default StreamingMarkdownContent;
import { forwardRef, useState, useRef, useEffect } from "react";
import type { ThinkingLevel } from "./ChatForm";
const THINKING_LEVELS = {
LOW: "low",
MEDIUM: "medium",
HIGH: "high",
} as const;
const THINKING_LEVEL_LABELS = {
low: "Low",
medium: "Medium",
high: "High",
} as const;
interface ThinkButtonProps {
mode: "think" | "thinkingLevel";
isVisible?: boolean;
isActive?: boolean;
currentLevel?: ThinkingLevel;
onToggle?: () => void;
onLevelChange?: (level: ThinkingLevel) => void;
onDropdownToggle?: (isOpen: boolean) => void;
}
export const ThinkButton = forwardRef<HTMLButtonElement, ThinkButtonProps>(
function ThinkButton(
{
mode,
isVisible,
isActive,
currentLevel,
onToggle,
onLevelChange,
onDropdownToggle,
},
ref,
) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (
ref &&
typeof ref === "object" &&
ref.current &&
mode === "thinkingLevel"
) {
(ref.current as any).closeDropdown = () => setIsDropdownOpen(false);
}
}, [ref, mode]);
useEffect(() => {
if (mode !== "thinkingLevel" || !isDropdownOpen) return;
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}, [isDropdownOpen, mode]);
if (!isVisible) return null;
if (mode === "think") {
return (
<button
ref={ref}
title={isActive ? "Disable think mode" : "Enable think mode"}
onClick={onToggle}
className={`select-none flex items-center justify-center rounded-full h-9 w-9 bg-white dark:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer transition-all whitespace-nowrap border border-transparent ${
isActive
? "text-[rgba(0,115,255,1)] dark:text-[rgba(70,155,255,1)]"
: "text-neutral-500 dark:text-neutral-400"
}`}
>
<svg
className="w-3 flex-none fill-current"
viewBox="0 0 11 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 4.8125C0 7.8125 1.79688 8.55469 2.29688 13.7656C2.32812 14.0469 2.48438 14.2266 2.78125 14.2266H7.67188C7.97656 14.2266 8.13281 14.0469 8.16406 13.7656C8.66406 8.55469 10.4531 7.8125 10.4531 4.8125C10.4531 2.11719 8.14844 0 5.22656 0C2.30469 0 0 2.11719 0 4.8125ZM1.17969 4.8125C1.17969 2.70312 3.03125 1.17969 5.22656 1.17969C7.42188 1.17969 9.27344 2.70312 9.27344 4.8125C9.27344 7.05469 7.78906 7.58594 7.08594 13.0469H3.375C2.66406 7.58594 1.17969 7.05469 1.17969 4.8125ZM2.75781 15.9141H7.70312C7.96094 15.9141 8.15625 15.7109 8.15625 15.4531C8.15625 15.2031 7.96094 15 7.70312 15H2.75781C2.5 15 2.29688 15.2031 2.29688 15.4531C2.29688 15.7109 2.5 15.9141 2.75781 15.9141ZM5.22656 18.1797C6.4375 18.1797 7.44531 17.5859 7.52344 16.6875H2.9375C2.99219 17.5859 4.00781 18.1797 5.22656 18.1797Z" />
</svg>
</button>
);
}
// thinkingLevel mode
const displayLabel = currentLevel
? THINKING_LEVEL_LABELS[currentLevel]
: "";
return (
<div className="relative" ref={dropdownRef}>
<button
ref={ref}
title={`Thinking level: ${displayLabel}`}
onClick={() => {
const newState = !isDropdownOpen;
setIsDropdownOpen(newState);
onDropdownToggle?.(newState);
}}
className={`select-none flex items-center justify-center gap-1 rounded-full h-9 px-3 bg-white dark:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer transition-all whitespace-nowrap border border-transparent text-[rgba(0,115,255,1)] dark:text-[rgba(70,155,255,1)]`}
>
<div className="justify-center items-center flex space-x-2">
<svg
className="w-3 flex-none fill-current"
viewBox="0 0 11 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 4.8125C0 7.8125 1.79688 8.55469 2.29688 13.7656C2.32812 14.0469 2.48438 14.2266 2.78125 14.2266H7.67188C7.97656 14.2266 8.13281 14.0469 8.16406 13.7656C8.66406 8.55469 10.4531 7.8125 10.4531 4.8125C10.4531 2.11719 8.14844 0 5.22656 0C2.30469 0 0 2.11719 0 4.8125ZM1.17969 4.8125C1.17969 2.70312 3.03125 1.17969 5.22656 1.17969C7.42188 1.17969 9.27344 2.70312 9.27344 4.8125C9.27344 7.05469 7.78906 7.58594 7.08594 13.0469H3.375C2.66406 7.58594 1.17969 7.05469 1.17969 4.8125ZM2.75781 15.9141H7.70312C7.96094 15.9141 8.15625 15.7109 8.15625 15.4531C8.15625 15.2031 7.96094 15 7.70312 15H2.75781C2.5 15 2.29688 15.2031 2.29688 15.4531C2.29688 15.7109 2.5 15.9141 2.75781 15.9141ZM5.22656 18.1797C6.4375 18.1797 7.44531 17.5859 7.52344 16.6875H2.9375C2.99219 17.5859 4.00781 18.1797 5.22656 18.1797Z" />
</svg>
<span className="text-sm">{displayLabel}</span>
</div>
<svg
className={`w-3 h-3`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{isDropdownOpen && (
<div className="absolute bottom-full mb-2 text-[15px] rounded-2xl overflow-hidden bg-white border border-neutral-100 text-neutral-800 shadow-xl shadow-black/5 backdrop-blur-lg dark:border-neutral-600/40 dark:bg-neutral-800 dark:text-white dark:ring-black/20 min-w-[120px]">
{Object.entries(THINKING_LEVELS).map(([, level]) => (
<button
key={level}
className={`w-full text-left px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors text-neutral-700 dark:text-neutral-300 ${
currentLevel === level
? "bg-neutral-100 dark:bg-neutral-700/60"
: ""
}`}
onClick={() => {
onLevelChange?.(level);
setIsDropdownOpen(false);
}}
>
{THINKING_LEVEL_LABELS[level]}
</button>
))}
</div>
)}
</div>
);
},
);
import { useEffect, useState, useRef } from "react";
import StreamingMarkdownContent from "./StreamingMarkdownContent";
export default function Thinking({
thinking,
startTime,
endTime,
}: {
thinking: string;
startTime?: Date;
endTime?: Date;
}) {
const [isCollapsed, setIsCollapsed] = useState(true);
const [hasUserInteracted, setHasUserInteracted] = useState(false);
const [contentHeight, setContentHeight] = useState<number>(0);
const [hasOverflow, setHasOverflow] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const activelyThinking = startTime && !endTime;
const finishedThinking = startTime && endTime;
// Auto-collapse when thinking is done (only if user hasn't manually interacted)
useEffect(() => {
if (endTime && !hasUserInteracted) {
setIsCollapsed(true);
}
}, [endTime, hasUserInteracted]);
// Reset user interaction flag when a new thinking session starts
useEffect(() => {
if (activelyThinking) {
setHasUserInteracted(false);
}
}, [activelyThinking]);
// Measure content height for animations
useEffect(() => {
if (contentRef.current) {
const resizeObserver = new ResizeObserver(() => {
if (contentRef.current) {
setContentHeight(contentRef.current.scrollHeight);
}
});
resizeObserver.observe(contentRef.current);
return () => resizeObserver.disconnect();
}
}, [thinking]);
// Position content to show bottom when collapsed
useEffect(() => {
if (isCollapsed && contentRef.current && wrapperRef.current) {
const contentHeight = contentRef.current.scrollHeight;
const wrapperHeight = wrapperRef.current.clientHeight;
if (contentHeight > wrapperHeight) {
const translateY = -(contentHeight - wrapperHeight);
contentRef.current.style.transform = `translateY(${translateY}px)`;
setHasOverflow(true);
} else {
setHasOverflow(false);
}
} else if (contentRef.current) {
contentRef.current.style.transform = "translateY(0)";
setHasOverflow(false);
}
}, [thinking, isCollapsed]);
const handleToggle = () => {
setIsCollapsed(!isCollapsed);
setHasUserInteracted(true);
};
// Calculate max height for smooth animations
const getMaxHeight = () => {
if (isCollapsed) {
return finishedThinking ? "0px" : "12rem"; // 8rem = 128px (same as max-h-32)
}
return contentHeight ? `${contentHeight}px` : "none";
};
return (
<div
className={`flex mb-4 flex-col w-full ${activelyThinking || !isCollapsed ? "text-neutral-800 dark:text-neutral-200" : "text-neutral-600 dark:text-neutral-400"}
hover:text-neutral-800
dark:hover:text-neutral-200 transition-colors`}
>
<div
className="flex items-center cursor-pointer group/thinking self-start relative select-text"
onClick={handleToggle}
>
{/* Light bulb */}
<svg
className={`w-3 absolute left-0 top-1/2 -translate-y-1/2 transition-opacity ${
isCollapsed ? "opacity-100" : "opacity-0"
} group-hover/thinking:opacity-0 fill-current will-change-opacity`}
viewBox="0 0 14 24"
fill="none"
>
<path d="M0 6.01562C0 9.76562 2.24609 10.6934 2.87109 17.207C2.91016 17.5586 3.10547 17.7832 3.47656 17.7832H9.58984C9.9707 17.7832 10.166 17.5586 10.2051 17.207C10.8301 10.6934 13.0664 9.76562 13.0664 6.01562C13.0664 2.64648 10.1855 0 6.5332 0C2.88086 0 0 2.64648 0 6.01562ZM1.47461 6.01562C1.47461 3.37891 3.78906 1.47461 6.5332 1.47461C9.27734 1.47461 11.5918 3.37891 11.5918 6.01562C11.5918 8.81836 9.73633 9.48242 8.85742 16.3086H4.21875C3.33008 9.48242 1.47461 8.81836 1.47461 6.01562ZM3.44727 19.8926H9.62891C9.95117 19.8926 10.1953 19.6387 10.1953 19.3164C10.1953 19.0039 9.95117 18.75 9.62891 18.75H3.44727C3.125 18.75 2.87109 19.0039 2.87109 19.3164C2.87109 19.6387 3.125 19.8926 3.44727 19.8926ZM6.5332 22.7246C8.04688 22.7246 9.30664 21.9824 9.4043 20.8594H3.67188C3.74023 21.9824 5.00977 22.7246 6.5332 22.7246Z" />
</svg>
{/* Arrow */}
<svg
className={`h-4 w-4 absolute transition-all ${
isCollapsed
? "-rotate-90 opacity-0 group-hover/thinking:opacity-100"
: "rotate-0 opacity-100"
} will-change-[opacity,transform]`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<h3 className="ml-6 select-text">
{activelyThinking
? "Thinking..."
: finishedThinking
? (() => {
const thinkingTime =
(endTime.getTime() - startTime.getTime()) / 1000;
return thinkingTime < 2
? "Thought for a moment"
: `Thought for ${thinkingTime.toFixed(1)} seconds`;
})()
: "Thinking..."}
</h3>
</div>
<div
ref={wrapperRef}
className={`text-xs text-neutral-500 dark:text-neutral-500 rounded-md overflow-hidden
transition-[max-height,opacity] duration-300 ease-in-out relative ml-6 mt-2`}
style={{
maxHeight: getMaxHeight(),
opacity: isCollapsed && finishedThinking ? 0 : 1,
}}
>
<div
ref={contentRef}
className="transition-transform duration-300 opacity-75 select-text"
>
<StreamingMarkdownContent
content={thinking}
isStreaming={activelyThinking}
size="sm"
/>
</div>
{/* Gradient overlay for fade effect when collapsed and scrolled */}
{isCollapsed && hasOverflow && (
<div className="absolute inset-x-0 -top-1 h-8 pointer-events-none bg-gradient-to-b from-white dark:from-neutral-900 to-transparent" />
)}
</div>
</div>
);
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment