Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 87 additions & 131 deletions apps/mail/components/create/ai-chat.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar';
import { useAIFullScreen, useAISidebar } from '../ui/ai-sidebar';
import { VoiceProvider } from '@/providers/voice-provider';
Expand All @@ -14,25 +13,26 @@ import { VoiceButton } from '../voice-button';
import { EditorContent } from '@tiptap/react';
import { CurvedArrow } from '../icons/icons';
import { Tools } from '../../types/tools';
import { InfoIcon } from 'lucide-react';
import { Button } from '../ui/button';
import { format } from 'date-fns-tz';
import { useQueryState } from 'nuqs';

const renderThread = (thread: { id: string; title: string; snippet: string }) => {
const ThreadPreview = ({ threadId }: { threadId: string }) => {
const [, setThreadId] = useQueryState('threadId');
const { data: getThread } = useThread(thread.id);
const { data: getThread } = useThread(threadId);
const [, setIsFullScreen] = useQueryState('isFullScreen');

const handleClick = () => {
setThreadId(thread.id);
setThreadId(threadId);
setIsFullScreen(null);
};

return getThread?.latest ? (
if (!getThread?.latest) return null;

return (
<div
onClick={handleClick}
key={thread.id}
key={threadId}
className="hover:bg-offsetLight/30 dark:hover:bg-offsetDark/30 cursor-pointer rounded-lg"
>
<div className="flex cursor-pointer items-center justify-between p-2">
Expand Down Expand Up @@ -65,15 +65,7 @@ const renderThread = (thread: { id: string; title: string; snippet: string }) =>
</div>
</div>
</div>
) : null;
};

const RenderThreads = ({
threads,
}: {
threads: { id: string; title: string; snippet: string }[];
}) => {
return <div className="flex flex-col gap-2">{threads.map(renderThread)}</div>;
);
};

const ExampleQueries = ({ onQueryClick }: { onQueryClick: (query: string) => void }) => {
Expand Down Expand Up @@ -148,82 +140,55 @@ export interface AIChatProps {
onModelChange?: (model: string) => void;
}

declare global {
var DEBUG: boolean;
}

const ToolResponse = ({ toolName, result, args }: { toolName: string; result: any; args: any }) => {
const renderContent = () => {
switch (toolName) {
case Tools.ListThreads:
case Tools.AskZeroMailbox:
return result?.threads ? <RenderThreads threads={result.threads} /> : null;

case Tools.GetThread:
return result?.thread ? (
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-800">
<div className="mb-2 flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={getEmailLogo(result.thread.sender?.email)} />
<AvatarFallback>{result.thread.sender?.name?.[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{result.thread.sender?.name}</p>
<p className="text-sm text-gray-500">{result.thread.subject}</p>
</div>
</div>
<div className="prose dark:prose-invert max-w-none">
<Markdown>{result.thread.body}</Markdown>
</div>
</div>
) : null;

case Tools.GetUserLabels:
return result?.labels ? (
<div className="flex flex-wrap gap-2">
{result.labels.map((label: any) => (
<MailLabels key={label.id} labels={[label]} />
))}
</div>
) : null;

case Tools.ComposeEmail:
return result?.newBody ? (
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-800">
<div className="prose dark:prose-invert max-w-none">
<Markdown>{result.newBody}</Markdown>
</div>
</div>
) : null;

default:
return null;
}
};
// Subcomponents for ToolResponse
const GetThreadToolResponse = ({ result, args }: { result: any; args: any }) => {
// Extract threadId from result or args
let threadId: string | null = null;
if (typeof result === 'string') {
const match = result.match(/<thread id="([^"]+)" ?\/>/);
if (match?.[1]) threadId = match[1];
}
if (!threadId && args?.id && typeof args.id === 'string') threadId = args.id;
if (!threadId) return null;
return <ThreadPreview threadId={threadId} />;
};

const content = renderContent();
if (!content) return null;
const GetUserLabelsToolResponse = ({ result }: { result: any }) => {
if (!result?.labels) return null;
return (
<div className="flex flex-wrap gap-2">
{result.labels.map((label: any) => (
<MailLabels key={label.id} labels={[label]} />
))}
</div>
);
};

const ComposeEmailToolResponse = ({ result }: { result: any }) => {
if (!result?.newBody) return null;
return (
<div className="group relative space-y-2">
{globalThis.DEBUG ? (
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="fill-subtleWhite text-subtleBlack dark:fill-subtleBlack h-4 w-4 dark:text-[#373737]" />
</TooltipTrigger>
<TooltipContent>
<div className="text-xs">
<p className="mb-1 font-medium">Tool Arguments:</p>
<pre className="whitespace-pre-wrap break-words">{JSON.stringify(args, null, 2)}</pre>
</div>
</TooltipContent>
</Tooltip>
) : null}
{content}
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-800">
<div className="prose dark:prose-invert max-w-none">
<Markdown>{result.newBody}</Markdown>
</div>
</div>
);
};

// Main ToolResponse switcher
const ToolResponse = ({ toolName, result, args }: { toolName: string; result: any; args: any }) => {
switch (toolName) {
case Tools.GetThread:
return <GetThreadToolResponse result={result} args={args} />;
case Tools.GetUserLabels:
return <GetUserLabelsToolResponse result={result} />;
case Tools.ComposeEmail:
return <ComposeEmailToolResponse result={result} />;
default:
return null;
}
};

export function AIChat({
messages,
setInput,
Expand Down Expand Up @@ -320,28 +285,22 @@ export function AIChat({
messages.map((message, index) => {
const textParts = message.parts.filter((part) => part.type === 'text');
const toolParts = message.parts.filter((part) => part.type === 'tool-invocation');
const streamingTools = new Set([Tools.WebSearch]);
const doesIncludeStreamingTool = toolParts.some(
(part) =>
streamingTools.has(part.toolInvocation?.toolName as Tools) &&
part.toolInvocation?.result,
);

return (
<div key={`${message.id}-${index}`} className="flex flex-col">
{toolParts.map((part) =>
part.toolInvocation &&
part.toolInvocation.result &&
!streamingTools.has(part.toolInvocation.toolName as Tools) ? (
<ToolResponse
key={part.toolInvocation.toolName}
toolName={part.toolInvocation.toolName}
result={part.toolInvocation.result}
args={part.toolInvocation.args}
/>
) : null,
<div key={`${message.id}-${index}`} className="mb-2 flex flex-col">
{toolParts.map(
(part, index) =>
part.toolInvocation?.result && (
<ToolResponse
key={`${part.toolInvocation.toolName}-${index}`}
toolName={part.toolInvocation.toolName}
result={part.toolInvocation.result}
args={part.toolInvocation.args}
/>
),
)}
{!doesIncludeStreamingTool && textParts.length > 0 && (
<p
{textParts.length > 0 && (
<div
className={cn(
'flex w-fit flex-col gap-2 rounded-lg text-sm',
message.role === 'user'
Expand All @@ -354,48 +313,45 @@ export function AIChat({
part.text && (
<Markdown
markdownCustomStyles={{
h1: {
fontSize: '1rem',
},
h2: {
fontSize: '1rem',
},
h3: {
fontSize: '1rem',
},
h4: {
fontSize: '1rem',
},
h5: {
fontSize: '1rem',
},
h6: {
fontSize: '1rem',
},
p: {
h1: { fontSize: '1rem' },
h2: { fontSize: '1rem' },
h3: { fontSize: '1rem' },
h4: { fontSize: '1rem' },
h5: { fontSize: '1rem' },
h6: { fontSize: '1rem' },
p: { fontSize: '1rem' },
li: {
fontSize: '1rem',
marginBottom: '0.25rem',
listStyleType: 'disc',
listStylePosition: 'inside',
},
ul: { fontSize: '1rem' },
ol: { fontSize: '1rem' },
blockQuote: { fontSize: '1rem' },
codeBlock: { fontSize: '1rem' },
codeInline: { fontSize: '1rem' },
link: { fontSize: '1rem' },
image: { fontSize: '1rem' },
}}
key={part.text}
>
{part.text || ' '}
</Markdown>
),
)}
</p>
</div>
)}
</div>
);
})
)}

{(status === 'submitted' || status === 'streaming') && (
<div className="flex flex-col gap-2 rounded-lg">
<div className="flex items-center gap-2">
<TextShimmer className="text-muted-foreground text-sm">
zero is thinking...
</TextShimmer>
</div>
<div className="absolute bottom-0 ml-2 flex items-center gap-2">
<TextShimmer className="text-muted-foreground text-xs">
zero is thinking...
</TextShimmer>
</div>
)}
{(status === 'error' || !!error) && (
Expand Down
7 changes: 5 additions & 2 deletions apps/mail/components/mail/mail-display.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ const ThreadAttachments = ({ attachments }: { attachments: Attachment[] }) => {
<div className="mt-2 flex flex-wrap gap-2">
{attachments.map((attachment) => (
<button
key={attachment.attachmentId}
key={`${attachment.attachmentId}-${attachment.filename}`}
onClick={() => handleDownload(attachment)}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-sm hover:bg-[#F0F0F0] dark:bg-[#262626] dark:hover:bg-[#303030]"
>
Expand Down Expand Up @@ -1641,7 +1641,10 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
{emailData?.attachments && emailData?.attachments.length > 0 ? (
<div className="mb-4 flex flex-wrap items-center gap-2 px-4 pt-4">
{emailData?.attachments.map((attachment) => (
<div key={attachment.filename} className="flex">
<div
key={`${attachment.filename}-${attachment.attachmentId}`}
className="flex"
>
<button
className="flex cursor-pointer items-center gap-1 rounded-[5px] bg-[#FAFAFA] px-1.5 py-1 text-sm font-medium hover:bg-[#F0F0F0] dark:bg-[#262626] dark:hover:bg-[#303030]"
onClick={() => openAttachment(attachment)}
Expand Down
2 changes: 1 addition & 1 deletion apps/mail/components/mail/select-all-checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default function SelectAllCheckbox({ className }: { className?: string })
const page = await trpcClient.mail.listThreads.query({
folder,
q: query,
max: MAX_PER_PAGE,
maxResults: MAX_PER_PAGE,
cursor,
});
if (page?.threads?.length) {
Expand Down
13 changes: 3 additions & 10 deletions apps/mail/components/ui/ai-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -334,15 +334,8 @@ export function useAISidebar() {
}

function AISidebar({ className }: AISidebarProps) {
const {
open,
setOpen,
isFullScreen,
setIsFullScreen,
toggleViewMode,
isSidebar,
isPopup,
} = useAISidebar();
const { open, setOpen, isFullScreen, setIsFullScreen, toggleViewMode, isSidebar, isPopup } =
useAISidebar();
const { isPro, track, refetch: refetchBilling } = useBilling();
const queryClient = useQueryClient();
const trpc = useTRPC();
Expand All @@ -360,7 +353,7 @@ function AISidebar({ className }: AISidebarProps) {

const chatState = useAgentChat({
agent,
maxSteps: 5,
maxSteps: 10,
body: {
threadId: threadId ?? undefined,
currentFolder: folder ?? undefined,
Expand Down
Loading