fix: resolve scroll flickering on message send and improve thinking state animation (#1333)

This commit is contained in:
josh 2025-11-29 13:02:24 +00:00 committed by GitHub
parent d0b6f37aee
commit a3802348fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 225 additions and 174 deletions

View file

@ -3,8 +3,8 @@
import { generateText, type UIMessage } from "ai";
import { cookies } from "next/headers";
import type { VisibilityType } from "@/components/visibility-selector";
import { myProvider } from "@/lib/ai/providers";
import { titlePrompt } from "@/lib/ai/prompts";
import { myProvider } from "@/lib/ai/providers";
import {
deleteMessagesByChatIdAfterTimestamp,
getMessageById,

View file

@ -1,6 +1,6 @@
import type { NextRequest } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getChatsByUserId, deleteAllChatsByUserId } from "@/lib/db/queries";
import { deleteAllChatsByUserId, getChatsByUserId } from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
export async function GET(request: NextRequest) {

View file

@ -1,11 +1,9 @@
@import "tailwindcss";
@import "katex/dist/katex.min.css";
/* include utility classes in streamdown */
@source '../node_modules/streamdown/dist/index.js';
/* Add KaTeX CSS for math rendering */
@import 'katex/dist/katex.min.css';
/* custom variant for setting dark mode programmatically */
@custom-variant dark (&:is(.dark, .dark *));

View file

@ -8,7 +8,10 @@ import { toast } from "sonner";
import { useSWRConfig } from "swr";
import { unstable_serialize } from "swr/infinite";
import { PlusIcon, TrashIcon } from "@/components/icons";
import { SidebarHistory, getChatHistoryPaginationKey } from "@/components/sidebar-history";
import {
getChatHistoryPaginationKey,
SidebarHistory,
} from "@/components/sidebar-history";
import { SidebarUserNav } from "@/components/sidebar-user-nav";
import { Button } from "@/components/ui/button";
import {
@ -19,7 +22,6 @@ import {
SidebarMenu,
useSidebar,
} from "@/components/ui/sidebar";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import {
AlertDialog,
AlertDialogAction,
@ -30,6 +32,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "./ui/alert-dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export function AppSidebar({ user }: { user: User | undefined }) {
const router = useRouter();
@ -118,13 +121,16 @@ export function AppSidebar({ user }: { user: User | undefined }) {
<SidebarFooter>{user && <SidebarUserNav user={user} />}</SidebarFooter>
</Sidebar>
<AlertDialog onOpenChange={setShowDeleteAllDialog} open={showDeleteAllDialog}>
<AlertDialog
onOpenChange={setShowDeleteAllDialog}
open={showDeleteAllDialog}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete all chats?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete all your
chats and remove them from our servers.
This action cannot be undone. This will permanently delete all
your chats and remove them from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

View file

@ -1,12 +1,12 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect } from "react";
import { initialArtifactData, useArtifact } from "@/hooks/use-artifact";
import { artifactDefinitions } from "./artifact";
import { useDataStream } from "./data-stream-provider";
export function DataStreamHandler() {
const { dataStream,setDataStream } = useDataStream();
const { dataStream, setDataStream } = useDataStream();
const { artifact, setArtifact, setMetadata } = useArtifact();
@ -77,7 +77,7 @@ export function DataStreamHandler() {
}
});
}
}, [dataStream, setArtifact, setMetadata, artifact]);
}, [dataStream, setArtifact, setMetadata, artifact, setDataStream]);
return null;
}

View file

@ -114,7 +114,7 @@ export const Context = ({ className, usage, ...props }: ContextProps) => {
className={cn(
"inline-flex select-none items-center gap-1 rounded-md text-sm",
"cursor-pointer bg-background text-foreground",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 outline-none ring-offset-background",
"outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
type="button"

View file

@ -1,7 +1,6 @@
"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import equal from "fast-deep-equal";
import { motion } from "framer-motion";
import { memo, useState } from "react";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
@ -33,7 +32,7 @@ const PurePreviewMessage = ({
setMessages,
regenerate,
isReadonly,
requiresScrollPadding,
requiresScrollPadding: _requiresScrollPadding,
}: {
chatId: string;
message: ChatMessage;
@ -53,12 +52,10 @@ const PurePreviewMessage = ({
useDataStream();
return (
<motion.div
animate={{ opacity: 1 }}
className="group/message w-full"
<div
className="group/message fade-in w-full animate-in duration-200"
data-role={message.role}
data-testid={`message-${message.role}`}
initial={{ opacity: 0 }}
>
<div
className={cn("flex w-full items-start gap-2 md:gap-3", {
@ -77,7 +74,6 @@ const PurePreviewMessage = ({
"gap-2 md:gap-4": message.parts?.some(
(p) => p.type === "text" && p.text?.trim()
),
"min-h-96": message.role === "assistant" && requiresScrollPadding,
"w-full":
(message.role === "assistant" &&
message.parts?.some(
@ -282,7 +278,7 @@ const PurePreviewMessage = ({
)}
</div>
</div>
</motion.div>
</div>
);
};
@ -310,30 +306,30 @@ export const PreviewMessage = memo(
);
export const ThinkingMessage = () => {
const role = "assistant";
return (
<motion.div
animate={{ opacity: 1 }}
className="group/message w-full"
data-role={role}
<div
className="group/message fade-in w-full animate-in duration-300"
data-role="assistant"
data-testid="message-assistant-loading"
exit={{ opacity: 0, transition: { duration: 0.5 } }}
initial={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="flex items-start justify-start gap-3">
<div className="-mt-1 flex size-8 shrink-0 items-center justify-center rounded-full bg-background ring-1 ring-border">
<SparklesIcon size={14} />
<div className="animate-pulse">
<SparklesIcon size={14} />
</div>
</div>
<div className="flex w-full flex-col gap-2 md:gap-4">
<div className="p-0 text-muted-foreground text-sm">
Thinking...
<div className="flex items-center gap-1 p-0 text-muted-foreground text-sm">
<span className="animate-pulse">Thinking</span>
<span className="inline-flex">
<span className="animate-bounce [animation-delay:0ms]">.</span>
<span className="animate-bounce [animation-delay:150ms]">.</span>
<span className="animate-bounce [animation-delay:300ms]">.</span>
</span>
</div>
</div>
</div>
</motion.div>
</div>
);
};

View file

@ -1,13 +1,11 @@
import type { UseChatHelpers } from "@ai-sdk/react";
import equal from "fast-deep-equal";
import { AnimatePresence } from "framer-motion";
import { ArrowDownIcon } from "lucide-react";
import { memo, useEffect } from "react";
import { memo } from "react";
import { useMessages } from "@/hooks/use-messages";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import { useDataStream } from "./data-stream-provider";
import { Conversation, ConversationContent } from "./elements/conversation";
import { Greeting } from "./greeting";
import { PreviewMessage, ThinkingMessage } from "./message";
@ -31,7 +29,7 @@ function PureMessages({
setMessages,
regenerate,
isReadonly,
selectedModelId,
selectedModelId: _selectedModelId,
}: MessagesProps) {
const {
containerRef: messagesContainerRef,
@ -45,62 +43,41 @@ function PureMessages({
useDataStream();
useEffect(() => {
if (status === "submitted") {
requestAnimationFrame(() => {
const container = messagesContainerRef.current;
if (container) {
container.scrollTo({
top: container.scrollHeight,
behavior: "smooth",
});
}
});
}
}, [status, messagesContainerRef]);
return (
<div
className="overscroll-behavior-contain -webkit-overflow-scrolling-touch flex-1 touch-pan-y overflow-y-scroll"
className="relative flex-1 touch-pan-y overflow-y-auto"
ref={messagesContainerRef}
style={{ overflowAnchor: "none" }}
>
<Conversation className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 md:gap-6">
<ConversationContent className="flex flex-col gap-4 px-2 py-4 md:gap-6 md:px-4">
{messages.length === 0 && <Greeting />}
<div className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-2 py-4 md:gap-6 md:px-4">
{messages.length === 0 && <Greeting />}
{messages.map((message, index) => (
<PreviewMessage
chatId={chatId}
isLoading={
status === "streaming" && messages.length - 1 === index
}
isReadonly={isReadonly}
key={message.id}
message={message}
regenerate={regenerate}
requiresScrollPadding={
hasSentMessage && index === messages.length - 1
}
setMessages={setMessages}
vote={
votes
? votes.find((vote) => vote.messageId === message.id)
: undefined
}
/>
))}
<AnimatePresence mode="wait">
{status === "submitted" && <ThinkingMessage key="thinking" />}
</AnimatePresence>
<div
className="min-h-[24px] min-w-[24px] shrink-0"
ref={messagesEndRef}
{messages.map((message, index) => (
<PreviewMessage
chatId={chatId}
isLoading={status === "streaming" && messages.length - 1 === index}
isReadonly={isReadonly}
key={message.id}
message={message}
regenerate={regenerate}
requiresScrollPadding={
hasSentMessage && index === messages.length - 1
}
setMessages={setMessages}
vote={
votes
? votes.find((vote) => vote.messageId === message.id)
: undefined
}
/>
</ConversationContent>
</Conversation>
))}
{status === "submitted" && <ThinkingMessage />}
<div
className="min-h-[24px] min-w-[24px] shrink-0"
ref={messagesEndRef}
/>
</div>
{!isAtBottom && (
<button

View file

@ -21,7 +21,6 @@ import { useLocalStorage, useWindowSize } from "usehooks-ts";
import { saveChatModelAsCookie } from "@/app/(chat)/actions";
import { SelectItem } from "@/components/ui/select";
import { chatModels } from "@/lib/ai/models";
import { myProvider } from "@/lib/ai/providers";
import type { Attachment, ChatMessage } from "@/lib/types";
import type { AppUsage } from "@/lib/usage";
import { cn } from "@/lib/utils";
@ -195,10 +194,6 @@ function PureMultimodalInput({
}
}, []);
const _modelResolver = useMemo(() => {
return myProvider.languageModel(selectedModelId);
}, [selectedModelId]);
const contextProps = useMemo(
() => ({
usage,
@ -231,36 +226,39 @@ function PureMultimodalInput({
},
[setAttachments, uploadFile]
);
const handlePaste = useCallback(
async (event: ClipboardEvent) => {
const items = event.clipboardData?.items;
if (!items) return;
if (!items) {
return;
}
const imageItems = Array.from(items).filter((item) =>
item.type.startsWith('image/'),
item.type.startsWith("image/")
);
if (imageItems.length === 0) return;
if (imageItems.length === 0) {
return;
}
// Prevent default paste behavior for images
event.preventDefault();
setUploadQueue((prev) => [...prev, 'Pasted image']);
setUploadQueue((prev) => [...prev, "Pasted image"]);
try {
const uploadPromises = imageItems.map(async (item) => {
const file = item.getAsFile();
if (!file) return;
return uploadFile(file);
});
const uploadPromises = imageItems
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null)
.map((file) => uploadFile(file));
const uploadedAttachments = await Promise.all(uploadPromises);
const successfullyUploadedAttachments = uploadedAttachments.filter(
(attachment) =>
attachment !== undefined &&
attachment.url !== undefined &&
attachment.contentType !== undefined,
attachment.contentType !== undefined
);
setAttachments((curr) => [
@ -268,22 +266,24 @@ function PureMultimodalInput({
...(successfullyUploadedAttachments as Attachment[]),
]);
} catch (error) {
console.error('Error uploading pasted images:', error);
toast.error('Failed to upload pasted image(s)');
console.error("Error uploading pasted images:", error);
toast.error("Failed to upload pasted image(s)");
} finally {
setUploadQueue([]);
}
},
[setAttachments],
[setAttachments, uploadFile]
);
// Add paste event listener to textarea
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
if (!textarea) {
return;
}
textarea.addEventListener('paste', handlePaste);
return () => textarea.removeEventListener('paste', handlePaste);
textarea.addEventListener("paste", handlePaste);
return () => textarea.removeEventListener("paste", handlePaste);
}, [handlePaste]);
return (
@ -385,9 +385,9 @@ function PureMultimodalInput({
) : (
<PromptInputSubmit
className="size-8 rounded-full bg-primary text-primary-foreground transition-colors duration-200 hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
data-testid="send-button"
disabled={!input.trim() || uploadQueue.length > 0}
status={status}
data-testid="send-button"
>
<ArrowUpIcon size={14} />
</PromptInputSubmit>
@ -482,7 +482,7 @@ function PureModelSelectorCompact({
value={selectedModel?.name}
>
<Trigger asChild>
<Button variant="ghost" className="h-8 px-2">
<Button className="h-8 px-2" variant="ghost">
<CpuIcon size={16} />
<span className="hidden font-medium text-xs sm:block">
{selectedModel?.name}

View file

@ -36,9 +36,9 @@ export const PreviewAttachment = ({
{isUploading && (
<div
className="absolute inset-0 flex items-center justify-center bg-black/50"
data-testid="input-attachment-loader"
>
className="absolute inset-0 flex items-center justify-center bg-black/50"
data-testid="input-attachment-loader"
>
<Loader size={16} />
</div>
)}

View file

@ -5,28 +5,78 @@ import { format, isWithinInterval } from "date-fns";
import { useEffect, useState } from "react";
const SunIcon = ({ size = 40 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="5" fill="currentColor" />
<line x1="12" y1="1" x2="12" y2="3" stroke="currentColor" strokeWidth="2" />
<line x1="12" y1="21" x2="12" y2="23" stroke="currentColor" strokeWidth="2" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" stroke="currentColor" strokeWidth="2" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" stroke="currentColor" strokeWidth="2" />
<line x1="1" y1="12" x2="3" y2="12" stroke="currentColor" strokeWidth="2" />
<line x1="21" y1="12" x2="23" y2="12" stroke="currentColor" strokeWidth="2" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" stroke="currentColor" strokeWidth="2" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" stroke="currentColor" strokeWidth="2" />
<svg fill="none" height={size} viewBox="0 0 24 24" width={size}>
<circle cx="12" cy="12" fill="currentColor" r="5" />
<line stroke="currentColor" strokeWidth="2" x1="12" x2="12" y1="1" y2="3" />
<line
stroke="currentColor"
strokeWidth="2"
x1="12"
x2="12"
y1="21"
y2="23"
/>
<line
stroke="currentColor"
strokeWidth="2"
x1="4.22"
x2="5.64"
y1="4.22"
y2="5.64"
/>
<line
stroke="currentColor"
strokeWidth="2"
x1="18.36"
x2="19.78"
y1="18.36"
y2="19.78"
/>
<line stroke="currentColor" strokeWidth="2" x1="1" x2="3" y1="12" y2="12" />
<line
stroke="currentColor"
strokeWidth="2"
x1="21"
x2="23"
y1="12"
y2="12"
/>
<line
stroke="currentColor"
strokeWidth="2"
x1="4.22"
x2="5.64"
y1="19.78"
y2="18.36"
/>
<line
stroke="currentColor"
strokeWidth="2"
x1="18.36"
x2="19.78"
y1="5.64"
y2="4.22"
/>
</svg>
);
const MoonIcon = ({ size = 40 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path d="M21 12.79A9 9 0 1 1 11.21 3A7 7 0 0 0 21 12.79z" fill="currentColor" />
<svg fill="none" height={size} viewBox="0 0 24 24" width={size}>
<path
d="M21 12.79A9 9 0 1 1 11.21 3A7 7 0 0 0 21 12.79z"
fill="currentColor"
/>
</svg>
);
const CloudIcon = ({ size = 24 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" stroke="currentColor" strokeWidth="2" fill="none" />
<svg fill="none" height={size} viewBox="0 0 24 24" width={size}>
<path
d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"
fill="none"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
);
@ -273,39 +323,44 @@ export function Weather({
currentTimeIndex + hoursToShow
);
const location = weatherAtLocation.cityName ||
const location =
weatherAtLocation.cityName ||
`${weatherAtLocation.latitude?.toFixed(1)}°, ${weatherAtLocation.longitude?.toFixed(1)}°`;
return (
<div
className={cx(
"relative flex w-full flex-col gap-6 rounded-3xl p-6 shadow-lg overflow-hidden backdrop-blur-sm",
"relative flex w-full flex-col gap-6 overflow-hidden rounded-3xl p-6 shadow-lg backdrop-blur-sm",
{
"bg-gradient-to-br from-sky-400 via-blue-500 to-blue-600": isDay,
},
{
"bg-gradient-to-br from-indigo-900 via-purple-900 to-slate-900": !isDay,
"bg-gradient-to-br from-indigo-900 via-purple-900 to-slate-900":
!isDay,
}
)}
>
<div className="absolute inset-0 bg-white/10 backdrop-blur-sm" />
<div className="relative z-10">
<div className="flex items-center justify-between mb-4">
<div className="text-white/80 text-sm font-medium">
{location}
</div>
<div className="mb-4 flex items-center justify-between">
<div className="font-medium text-sm text-white/80">{location}</div>
<div className="text-white/60 text-xs">
{format(new Date(weatherAtLocation.current.time), "MMM d, h:mm a")}
</div>
</div>
<div className="flex items-center justify-between mb-6">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={cx("text-white/90", { "text-yellow-200": isDay, "text-blue-200": !isDay })}>
<div
className={cx("text-white/90", {
"text-yellow-200": isDay,
"text-blue-200": !isDay,
})}
>
{isDay ? <SunIcon size={48} /> : <MoonIcon size={48} />}
</div>
<div className="text-white text-5xl font-light">
<div className="font-light text-5xl text-white">
{n(weatherAtLocation.current.temperature_2m)}
<span className="text-2xl text-white/80">
{weatherAtLocation.current_units.temperature_2m}
@ -314,43 +369,47 @@ export function Weather({
</div>
<div className="text-right">
<div className="text-white/90 text-sm font-medium">
<div className="font-medium text-sm text-white/90">
H: {n(currentHigh)}°
</div>
<div className="text-white/70 text-sm">
L: {n(currentLow)}°
</div>
<div className="text-sm text-white/70">L: {n(currentLow)}°</div>
</div>
</div>
<div className="bg-white/10 rounded-2xl p-4 backdrop-blur-sm">
<div className="text-white/80 text-sm mb-3 font-medium">
<div className="rounded-2xl bg-white/10 p-4 backdrop-blur-sm">
<div className="mb-3 font-medium text-sm text-white/80">
Hourly Forecast
</div>
<div className="flex justify-between gap-2">
{displayTimes.map((time, index) => {
const hourTime = new Date(time);
const isCurrentHour = hourTime.getHours() === new Date().getHours();
const isCurrentHour =
hourTime.getHours() === new Date().getHours();
return (
<div
<div
className={cx(
"flex flex-col items-center gap-2 py-2 px-1 rounded-lg min-w-0 flex-1",
"flex min-w-0 flex-1 flex-col items-center gap-2 rounded-lg px-1 py-2",
{
"bg-white/20": isCurrentHour,
}
)}
)}
key={time}
>
<div className="text-white/70 text-xs font-medium">
<div className="font-medium text-white/70 text-xs">
{index === 0 ? "Now" : format(hourTime, "ha")}
</div>
<div className={cx("text-white/60", { "text-yellow-200": isDay, "text-blue-200": !isDay })}>
<div
className={cx("text-white/60", {
"text-yellow-200": isDay,
"text-blue-200": !isDay,
})}
>
<CloudIcon size={20} />
</div>
<div className="text-white text-sm font-medium">
<div className="font-medium text-sm text-white">
{n(displayTemperatures[index])}°
</div>
</div>
@ -359,9 +418,15 @@ export function Weather({
</div>
</div>
<div className="flex justify-between text-white/60 text-xs mt-4">
<div>Sunrise: {format(new Date(weatherAtLocation.daily.sunrise[0]), "h:mm a")}</div>
<div>Sunset: {format(new Date(weatherAtLocation.daily.sunset[0]), "h:mm a")}</div>
<div className="mt-4 flex justify-between text-white/60 text-xs">
<div>
Sunrise:{" "}
{format(new Date(weatherAtLocation.daily.sunrise[0]), "h:mm a")}
</div>
<div>
Sunset:{" "}
{format(new Date(weatherAtLocation.daily.sunset[0]), "h:mm a")}
</div>
</div>
</div>
</div>

View file

@ -117,4 +117,4 @@ export const titlePrompt = `\n
- you will generate a short title based on the first message a user begins a conversation with
- ensure it is not more than 80 characters long
- the title should be a summary of the user's message
- do not use quotes or colons`
- do not use quotes or colons`;

View file

@ -1,20 +1,24 @@
import { tool } from "ai";
import { z } from "zod";
async function geocodeCity(city: string): Promise<{ latitude: number; longitude: number } | null> {
async function geocodeCity(
city: string
): Promise<{ latitude: number; longitude: number } | null> {
try {
const response = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json`
);
if (!response.ok) return null;
if (!response.ok) {
return null;
}
const data = await response.json();
if (!data.results || data.results.length === 0) {
return null;
}
const result = data.results[0];
return {
latitude: result.latitude,
@ -26,11 +30,15 @@ async function geocodeCity(city: string): Promise<{ latitude: number; longitude:
}
export const getWeather = tool({
description: "Get the current weather at a location. You can provide either coordinates or a city name.",
description:
"Get the current weather at a location. You can provide either coordinates or a city name.",
inputSchema: z.object({
latitude: z.number().optional(),
longitude: z.number().optional(),
city: z.string().describe("City name (e.g., 'San Francisco', 'New York', 'London')").optional(),
city: z
.string()
.describe("City name (e.g., 'San Francisco', 'New York', 'London')")
.optional(),
}),
execute: async (input) => {
let latitude: number;
@ -50,7 +58,8 @@ export const getWeather = tool({
longitude = input.longitude;
} else {
return {
error: "Please provide either a city name or both latitude and longitude coordinates.",
error:
"Please provide either a city name or both latitude and longitude coordinates.",
};
}
@ -59,11 +68,11 @@ export const getWeather = tool({
);
const weatherData = await response.json();
if ("city" in input) {
weatherData.cityName = input.city;
}
return weatherData;
},
});

View file

@ -134,7 +134,7 @@ export async function deleteAllChatsByUserId({ userId }: { userId: string }) {
return { deletedCount: 0 };
}
const chatIds = userChats.map(c => c.id);
const chatIds = userChats.map((c) => c.id);
await db.delete(vote).where(inArray(vote.chatId, chatIds));
await db.delete(message).where(inArray(message.chatId, chatIds));