Hello there!
@@ -20,7 +20,7 @@ export const Greeting = () => {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ delay: 0.6 }}
- className="text-2xl text-zinc-500"
+ className="text-xl md:text-2xl text-zinc-500"
>
How can I help you today?
diff --git a/components/message-actions.tsx b/components/message-actions.tsx
index 8e85548..31373d0 100644
--- a/components/message-actions.tsx
+++ b/components/message-actions.tsx
@@ -3,7 +3,7 @@ import { useCopyToClipboard } from 'usehooks-ts';
import type { Vote } from '@/lib/db/schema';
-import { CopyIcon, ThumbDownIcon, ThumbUpIcon } from './icons';
+import { CopyIcon, ThumbDownIcon, ThumbUpIcon, PencilEditIcon } from './icons';
import { Actions, Action } from './elements/actions';
import { memo } from 'react';
import equal from 'fast-deep-equal';
@@ -15,134 +15,156 @@ export function PureMessageActions({
message,
vote,
isLoading,
+ setMode,
}: {
chatId: string;
message: ChatMessage;
vote: Vote | undefined;
isLoading: boolean;
+ setMode?: (mode: 'view' | 'edit') => void;
}) {
const { mutate } = useSWRConfig();
const [_, copyToClipboard] = useCopyToClipboard();
if (isLoading) return null;
- if (message.role === 'user') return null;
+
+ const textFromParts = message.parts
+ ?.filter((part) => part.type === 'text')
+ .map((part) => part.text)
+ .join('\n')
+ .trim();
+
+ const handleCopy = async () => {
+ if (!textFromParts) {
+ toast.error("There's no text to copy!");
+ return;
+ }
+
+ await copyToClipboard(textFromParts);
+ toast.success('Copied to clipboard!');
+ };
+
+ // User messages get edit (on hover) and copy actions
+ if (message.role === 'user') {
+ return (
+
+
+ {setMode && (
+
setMode('edit')}
+ className="absolute top-0 -left-10 opacity-0 transition-opacity group-hover/message:opacity-100"
+ >
+
+
+ )}
+
+
+
+
+
+ );
+ }
return (
-
- {
- const textFromParts = message.parts
- ?.filter((part) => part.type === 'text')
- .map((part) => part.text)
- .join('\n')
- .trim();
+
+
+
+
- if (!textFromParts) {
- toast.error("There's no text to copy!");
- return;
- }
+ {
+ const upvote = fetch('/api/vote', {
+ method: 'PATCH',
+ body: JSON.stringify({
+ chatId,
+ messageId: message.id,
+ type: 'up',
+ }),
+ });
- await copyToClipboard(textFromParts);
- toast.success('Copied to clipboard!');
- }}
- >
-
-
+ toast.promise(upvote, {
+ loading: 'Upvoting Response...',
+ success: () => {
+ mutate>(
+ `/api/vote?chatId=${chatId}`,
+ (currentVotes) => {
+ if (!currentVotes) return [];
- {
- const upvote = fetch('/api/vote', {
- method: 'PATCH',
- body: JSON.stringify({
- chatId,
- messageId: message.id,
- type: 'up',
- }),
- });
+ const votesWithoutCurrent = currentVotes.filter(
+ (vote) => vote.messageId !== message.id,
+ );
- toast.promise(upvote, {
- loading: 'Upvoting Response...',
- success: () => {
- mutate>(
- `/api/vote?chatId=${chatId}`,
- (currentVotes) => {
- if (!currentVotes) return [];
+ return [
+ ...votesWithoutCurrent,
+ {
+ chatId,
+ messageId: message.id,
+ isUpvoted: true,
+ },
+ ];
+ },
+ { revalidate: false },
+ );
- const votesWithoutCurrent = currentVotes.filter(
- (vote) => vote.messageId !== message.id,
- );
+ return 'Upvoted Response!';
+ },
+ error: 'Failed to upvote response.',
+ });
+ }}
+ >
+
+
- return [
- ...votesWithoutCurrent,
- {
- chatId,
- messageId: message.id,
- isUpvoted: true,
- },
- ];
- },
- { revalidate: false },
- );
+ {
+ const downvote = fetch('/api/vote', {
+ method: 'PATCH',
+ body: JSON.stringify({
+ chatId,
+ messageId: message.id,
+ type: 'down',
+ }),
+ });
- return 'Upvoted Response!';
- },
- error: 'Failed to upvote response.',
- });
- }}
- >
-
-
+ toast.promise(downvote, {
+ loading: 'Downvoting Response...',
+ success: () => {
+ mutate>(
+ `/api/vote?chatId=${chatId}`,
+ (currentVotes) => {
+ if (!currentVotes) return [];
- {
- const downvote = fetch('/api/vote', {
- method: 'PATCH',
- body: JSON.stringify({
- chatId,
- messageId: message.id,
- type: 'down',
- }),
- });
+ const votesWithoutCurrent = currentVotes.filter(
+ (vote) => vote.messageId !== message.id,
+ );
- toast.promise(downvote, {
- loading: 'Downvoting Response...',
- success: () => {
- mutate>(
- `/api/vote?chatId=${chatId}`,
- (currentVotes) => {
- if (!currentVotes) return [];
+ return [
+ ...votesWithoutCurrent,
+ {
+ chatId,
+ messageId: message.id,
+ isUpvoted: false,
+ },
+ ];
+ },
+ { revalidate: false },
+ );
- const votesWithoutCurrent = currentVotes.filter(
- (vote) => vote.messageId !== message.id,
- );
-
- return [
- ...votesWithoutCurrent,
- {
- chatId,
- messageId: message.id,
- isUpvoted: false,
- },
- ];
- },
- { revalidate: false },
- );
-
- return 'Downvoted Response!';
- },
- error: 'Failed to downvote response.',
- });
- }}
- >
-
-
+ return 'Downvoted Response!';
+ },
+ error: 'Failed to downvote response.',
+ });
+ }}
+ >
+
+
);
}
diff --git a/components/message.tsx b/components/message.tsx
index 311a055..19dcb49 100644
--- a/components/message.tsx
+++ b/components/message.tsx
@@ -1,10 +1,9 @@
'use client';
-import cx from 'classnames';
-import { AnimatePresence, motion } from 'framer-motion';
+import { motion } from 'framer-motion';
import { memo, useState } from 'react';
import type { Vote } from '@/lib/db/schema';
import { DocumentToolResult } from './document';
-import { PencilEditIcon, SparklesIcon, LoaderIcon } from './icons';
+import { SparklesIcon } from './icons';
import { Response } from './elements/response';
import { MessageContent } from './elements/message';
import {
@@ -19,8 +18,6 @@ import { PreviewAttachment } from './preview-attachment';
import { Weather } from './weather';
import equal from 'fast-deep-equal';
import { cn, sanitizeText } from '@/lib/utils';
-import { Button } from './ui/button';
-import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { MessageEditor } from './message-editor';
import { DocumentPreview } from './document-preview';
import { MessageReasoning } from './message-reasoning';
@@ -28,9 +25,6 @@ import type { UseChatHelpers } from '@ai-sdk/react';
import type { ChatMessage } from '@/lib/types';
import { useDataStream } from './data-stream-provider';
-// Type narrowing is handled by TypeScript's control flow analysis
-// The AI SDK provides proper discriminated unions for tool calls
-
const PurePreviewMessage = ({
chatId,
message,
@@ -61,240 +55,234 @@ const PurePreviewMessage = ({
useDataStream();
return (
-
-
+
+ {message.role === 'assistant' && (
+
+
+
+ )}
+
p.type === 'text' && p.text?.trim(),
+ ),
+ 'min-h-96': message.role === 'assistant' && requiresScrollPadding,
+ 'w-full':
+ (message.role === 'assistant' &&
+ message.parts?.some(
+ (p) => p.type === 'text' && p.text?.trim(),
+ )) ||
+ mode === 'edit',
+ 'max-w-[90%] sm:max-w-[min(fit-content,80%)]':
message.role === 'user' && mode !== 'edit',
- 'justify-start -ml-3': message.role === 'assistant',
})}
>
- {message.role === 'assistant' && (
-
-
+ {attachmentsFromMessage.length > 0 && (
+
+ {attachmentsFromMessage.map((attachment) => (
+
+ ))}
)}
-
- {attachmentsFromMessage.length > 0 && (
-
- {attachmentsFromMessage.map((attachment) => (
-
- ))}
-
- )}
+ {message.parts?.map((part, index) => {
+ const { type } = part;
+ const key = `message-${message.id}-part-${index}`;
- {message.parts?.map((part, index) => {
- const { type } = part;
- const key = `message-${message.id}-part-${index}`;
+ if (type === 'reasoning' && part.text?.trim().length > 0) {
+ return (
+
+ );
+ }
- if (type === 'reasoning' && part.text?.trim().length > 0) {
+ if (type === 'text') {
+ if (mode === 'view') {
return (
-
- );
- }
-
- if (type === 'text') {
- if (mode === 'view') {
- return (
-
- {message.role === 'user' && !isReadonly && (
-
-
-
-
- Edit message
-
- )}
-
-
- {sanitizeText(part.text)}
-
-
- );
- }
-
- if (mode === 'edit') {
- return (
-
- );
- }
- }
-
- if (type === 'tool-getWeather') {
- const { toolCallId, state } = part;
-
- return (
-
-
-
- {state === 'input-available' && (
-
- )}
- {state === 'output-available' && (
- }
- errorText={undefined}
- />
- )}
-
-
- );
- }
-
- if (type === 'tool-createDocument') {
- const { toolCallId } = part;
-
- if (part.output && 'error' in part.output) {
- return (
-
- Error creating document: {String(part.output.error)}
-
- );
- }
-
- return (
-
- );
- }
-
- if (type === 'tool-updateDocument') {
- const { toolCallId } = part;
-
- if (part.output && 'error' in part.output) {
- return (
-
- Error updating document: {String(part.output.error)}
-
- );
- }
-
- return (
-
-
+ {sanitizeText(part.text)}
+
);
}
- if (type === 'tool-requestSuggestions') {
- const { toolCallId, state } = part;
-
+ if (mode === 'edit') {
return (
-
-
-
- {state === 'input-available' && (
-
- )}
- {state === 'output-available' && (
-
- Error: {String(part.output.error)}
-
- ) : (
-
- )
- }
- errorText={undefined}
- />
- )}
-
-
+
);
}
- })}
+ }
- {!isReadonly && (
-
- )}
-
+ if (type === 'tool-getWeather') {
+ const { toolCallId, state } = part;
+
+ return (
+
+
+
+ {state === 'input-available' && (
+
+ )}
+ {state === 'output-available' && (
+ }
+ errorText={undefined}
+ />
+ )}
+
+
+ );
+ }
+
+ if (type === 'tool-createDocument') {
+ const { toolCallId } = part;
+
+ if (part.output && 'error' in part.output) {
+ return (
+
+ Error creating document: {String(part.output.error)}
+
+ );
+ }
+
+ return (
+
+ );
+ }
+
+ if (type === 'tool-updateDocument') {
+ const { toolCallId } = part;
+
+ if (part.output && 'error' in part.output) {
+ return (
+
+ Error updating document: {String(part.output.error)}
+
+ );
+ }
+
+ return (
+
+
+
+ );
+ }
+
+ if (type === 'tool-requestSuggestions') {
+ const { toolCallId, state } = part;
+
+ return (
+
+
+
+ {state === 'input-available' && (
+
+ )}
+ {state === 'output-available' && (
+
+ Error: {String(part.output.error)}
+
+ ) : (
+
+ )
+ }
+ errorText={undefined}
+ />
+ )}
+
+
+ );
+ }
+ })}
+
+ {!isReadonly && (
+
+ )}
-
-
+
+
);
};
@@ -319,21 +307,44 @@ export const ThinkingMessage = () => {