mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
80595e6463
* fix: migrate to new facelift * add: max-w- for footer * update * DCO Remediation Commit for christopherkindl <53372002+christopherkindl@users.noreply.github.com> I, christopherkindl <53372002+christopherkindl@users.noreply.github.com>, hereby add my Signed-off-by to this commit:ba7353f1fcI, christopherkindl <53372002+christopherkindl@users.noreply.github.com>, hereby add my Signed-off-by to this commit:e83ca813b1I, christopherkindl <53372002+christopherkindl@users.noreply.github.com>, hereby add my Signed-off-by to this commit:8e69ff0d6bSigned-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * Fix: Mobile menu's "close on route change" useEffect has empty dependency array [], making it a no-op that never fires on actual route changes. This commit fixes the issue reported at docs/components/geistdocs/mobile-menu.tsx:80 ## Bug Analysis The `MobileMenu` component at `docs/components/geistdocs/mobile-menu.tsx` line 80 has a useEffect intended to close the menu on route changes: ```js useEffect(() => { setShow(false); }, []); ``` The empty dependency array `[]` means this effect runs exactly once — on initial mount — when `show` is already `false` (its initial state). This is a complete no-op. It never detects or responds to route changes. **When this manifests:** If a user opens the mobile menu and then navigates via browser back/forward buttons, the menu remains open and `document.body.style.overflow` stays set to `'hidden'`, completely locking page scroll. While clicking the `NavLink` items within the menu closes it via their `onClick={close}` handlers, programmatic/browser-level navigation bypasses those handlers. **Impact:** Users on mobile who use browser navigation while the menu is open will have a broken experience — the menu stays open overlaying the page and body scroll is locked. ## Fix Replaced the empty-dependency useEffect with the correct pattern already demonstrated in the sibling `sidebar.tsx` component: 1. Added `usePathname()` from `next/navigation` to track the current route. 2. Added a `useRef` to store the previous pathname. 3. Updated the useEffect to depend on `pathname`, comparing it to the previous value and closing the menu when a change is detected. This ensures the menu closes on any route change, whether triggered by clicking a link, browser back/forward, or programmatic navigation. Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com> Co-authored-by: christopherkindl <christopher.kindl@gmail.com> * fix(docs): use ds-gray-900 token for muted-foreground color Replace hardcoded oklch values with var(--ds-gray-900) for both light and dark mode to stay in sync with the design system. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * fix(docs): update world component colors to use 900-level tokens Align icon and badge colors with design system by switching from 500/600 shades to 900-level variants across WorldDetailHero and WorldTestingPerformance components. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * fix(docs): update world page colors and align hero layout with TOC grid - Align hero quick links with TOC sidebar using matching grid layout - Add shrink-0 to hero icons for consistent vertical alignment - Update benchmark bar colors (green for fastest, blue for others) - Remove extra px-4 padding from hero section Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * polish: /worlds page Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * feat(docs): add filterable world cards with section grouping Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * fix(docs): use radix checkbox with SSR placeholder to prevent layout shift Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * fix(docs): use badge tabs for world filters instead of checkboxes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * revert: restore .husky/pre-commit from main Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * fix(docs): improve benchmark history chart UI and API performance Benchmark history dialog: - Fix DialogContent accessibility warning (use DialogDescription) - Fix 2MB unstable_cache error by using in-memory cache for snapshot map - Add Geist-style underline variant to Tabs component - Add skeleton loading state matching stat cards + chart layout - Add Geist shimmer effect to Skeleton component (--ds-gray-100/200 gradient) - Add --ds-shadow-tooltip token and apply to chart tooltip - Use design tokens for all chart colors (--ds-blue-800, --ds-green-800, --ds-purple-900) - Use linear interpolation, solid gridlines (--ds-gray-400), no animations - Add 10% fill area under single-line charts, hide for multi-line - Add white ring to chart dots, equidistant x-axis labels - Stale-while-revalidate client cache for instant mode switching - Stat cards refactored to use .map() over a data array - Lock dialog height with min-h to prevent layout shift API performance: - Early exit on gh-pages pagination (cap at MAX_ITEMS * 3) - Use snapshot timestamp for releases (eliminates 30 serial API calls) - Increase fetch batch size from 10 to 30 - Remove unstable_cache, rely on in-memory + fetch-level caching Other: - Add recharts.d.ts type augmentation for React 19 compatibility Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * fix(docs): add missing geist tokens and utilities from homepage-visuals Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * [DO NOT MERGE][home] Adds vercel-com workflow visuals (#1674) * add: visualizations * DCO Remediation Commit for christopherkindl <53372002+christopherkindl@users.noreply.github.com> I, christopherkindl <53372002+christopherkindl@users.noreply.github.com>, hereby add my Signed-off-by to this commit:eefbc9744aSigned-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * fix: adjust bar border radius and add @container to feature cards Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * fix: use rounded-lg for bar border radius, add @container to wide card Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * revert: restore geistdocs.css from main Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> --------- Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(docs): add mobile docs bar to cookbook pages Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * fix(docs): add mobile docs bar to cookbook pages Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * fix(docs): increase hero section vertical spacing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * chore(docs): remove recharts React 19 type shim Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * fix(docs): hoist useTransform calls to top level of components Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * fix(docs): add optional chaining for world.features access Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * fix(docs): log warning when benchmark pagination cap is hit Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * fix(docs): make filter badges keyboard accessible Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * fix(docs): clean up setTimeout on unmount in globe path components Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * feat(docs): add full-width frameworks section to homepage Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * feat(docs): update navbar logo and add framework logos from geist symbols - Replace LogoWorkflowSdk with Geist symbols wordmark (LogoWorkflow) - Add currentColor framework logo icons from geistcn-assets - Add full-width frameworks section to homepage with colored logos - TanStack shown as "Coming soon" with analytics tracking Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * docs: add comment noting timestamp semantic change in benchmark API Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> * fix(docs): move frameworks section after effortless setup on homepage Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> --------- Signed-off-by: christopherkindl <53372002+christopherkindl@users.noreply.github.com> Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
455 lines
14 KiB
TypeScript
455 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import type { UIMessage } from '@ai-sdk/react';
|
|
import { useChat } from '@ai-sdk/react';
|
|
import { DefaultChatTransport } from 'ai';
|
|
import { useLiveQuery } from 'dexie-react-hooks';
|
|
import { ChevronRightIcon, Trash } from 'lucide-react';
|
|
import { Portal } from 'radix-ui';
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
import type { MyUIMessage } from '@/app/api/chat/types';
|
|
import {
|
|
Conversation,
|
|
ConversationContent,
|
|
ConversationScrollButton,
|
|
} from '@/components/ai-elements/conversation';
|
|
import {
|
|
Message,
|
|
MessageContent,
|
|
MessageResponse,
|
|
} from '@/components/ai-elements/message';
|
|
import {
|
|
PromptInput,
|
|
PromptInputBody,
|
|
PromptInputFooter,
|
|
type PromptInputProps,
|
|
PromptInputProvider,
|
|
PromptInputSubmit,
|
|
PromptInputTextarea,
|
|
} from '@/components/ai-elements/prompt-input';
|
|
import { Suggestion, Suggestions } from '@/components/ai-elements/suggestion';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer';
|
|
import { useChatContext } from '@/hooks/geistdocs/use-chat';
|
|
import { useIsMobile } from '@/hooks/use-mobile';
|
|
import { db } from '@/lib/geistdocs/db';
|
|
import { cn } from '@/lib/utils';
|
|
import { ButtonGroup } from '../ui/button-group';
|
|
import { Kbd, KbdGroup } from '../ui/kbd';
|
|
import { Spinner } from '../ui/spinner';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
|
import { CopyChat } from './copy-chat';
|
|
import { MessageMetadata } from './message-metadata';
|
|
|
|
const isFromPreviousDay = (timestamp: number): boolean => {
|
|
const messageDate = new Date(timestamp);
|
|
const today = new Date();
|
|
|
|
return (
|
|
messageDate.getFullYear() !== today.getFullYear() ||
|
|
messageDate.getMonth() !== today.getMonth() ||
|
|
messageDate.getDate() !== today.getDate()
|
|
);
|
|
};
|
|
|
|
export const useChatPersistence = () => {
|
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
|
undefined
|
|
);
|
|
|
|
// Load messages from Dexie with live query
|
|
const storedMessages = useLiveQuery(() =>
|
|
db.messages.orderBy('sequence').toArray()
|
|
);
|
|
|
|
// Clear messages if they're from a previous day
|
|
useEffect(() => {
|
|
if (storedMessages && storedMessages.length > 0) {
|
|
const firstMessage = storedMessages[0];
|
|
if (firstMessage && isFromPreviousDay(firstMessage.timestamp)) {
|
|
db.messages.clear();
|
|
}
|
|
}
|
|
}, [storedMessages]);
|
|
|
|
// Filter out stale messages from previous days
|
|
const freshMessages = storedMessages?.filter(
|
|
(msg) => !isFromPreviousDay(msg.timestamp)
|
|
);
|
|
|
|
const initialMessages =
|
|
freshMessages?.map(({ timestamp, sequence, ...message }) => message) ?? [];
|
|
|
|
const isLoading = storedMessages === undefined;
|
|
|
|
// Save messages to Dexie with debouncing
|
|
const saveMessages = useCallback((messages: UIMessage[]) => {
|
|
if (saveTimeoutRef.current) {
|
|
clearTimeout(saveTimeoutRef.current);
|
|
}
|
|
|
|
saveTimeoutRef.current = setTimeout(async () => {
|
|
try {
|
|
const baseTimestamp = Date.now();
|
|
const messagesToStore = messages.map((message, index) => ({
|
|
...message,
|
|
timestamp: baseTimestamp + index * 1000,
|
|
sequence: index,
|
|
}));
|
|
|
|
await db.transaction('rw', db.messages, async () => {
|
|
await db.messages.clear();
|
|
await db.messages.bulkAdd(messagesToStore);
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to save messages:', error);
|
|
}
|
|
}, 300);
|
|
}, []);
|
|
|
|
// Clear all messages from Dexie
|
|
const clearMessages = useCallback(async () => {
|
|
try {
|
|
await db.messages.clear();
|
|
} catch (error) {
|
|
console.error('Failed to clear messages:', error);
|
|
}
|
|
}, []);
|
|
|
|
// Cleanup timeout on unmount
|
|
useEffect(
|
|
() => () => {
|
|
if (saveTimeoutRef.current) {
|
|
clearTimeout(saveTimeoutRef.current);
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
return {
|
|
initialMessages,
|
|
isLoading,
|
|
saveMessages,
|
|
clearMessages,
|
|
};
|
|
};
|
|
|
|
interface ChatProps {
|
|
basePath: string | undefined;
|
|
suggestions: string[];
|
|
}
|
|
|
|
type ChatInnerProps = ChatProps & {
|
|
isOpen: boolean;
|
|
};
|
|
|
|
const ChatInner = ({ basePath, suggestions, isOpen }: ChatInnerProps) => {
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const [isInitialized, setIsInitialized] = useState(false);
|
|
const [localPrompt, setLocalPrompt] = useState('');
|
|
const [providerKey, setProviderKey] = useState(0);
|
|
const { prompt, setPrompt, setIsOpen } = useChatContext();
|
|
const { initialMessages, isLoading, saveMessages, clearMessages } =
|
|
useChatPersistence();
|
|
|
|
const { messages, sendMessage, status, setMessages, stop } = useChat({
|
|
transport: new DefaultChatTransport({
|
|
api: basePath ? `${basePath}/api/chat` : '/api/chat',
|
|
}),
|
|
onError: (error) => {
|
|
toast.error(error.message, {
|
|
description: error.message,
|
|
});
|
|
},
|
|
});
|
|
|
|
// Sync external prompt changes to local state and force provider remount
|
|
useEffect(() => {
|
|
if (prompt && prompt !== localPrompt) {
|
|
setLocalPrompt(prompt);
|
|
setProviderKey((prev) => prev + 1);
|
|
}
|
|
}, [prompt, localPrompt]);
|
|
|
|
// Set initial messages once loaded from IndexedDB
|
|
useEffect(() => {
|
|
if (!(isLoading || isInitialized) && initialMessages.length > 0) {
|
|
setMessages(initialMessages);
|
|
setIsInitialized(true);
|
|
} else if (!(isLoading || isInitialized)) {
|
|
// Mark as initialized even if no messages to avoid infinite re-runs
|
|
setIsInitialized(true);
|
|
}
|
|
}, [isLoading, initialMessages, isInitialized, setMessages]);
|
|
|
|
// Save messages to IndexedDB whenever they change (but only after initialization)
|
|
useEffect(() => {
|
|
if (isInitialized && messages.length > 0) {
|
|
saveMessages(messages);
|
|
}
|
|
}, [messages, saveMessages, isInitialized]);
|
|
|
|
// Focus textarea when chat opens
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
// Small delay to ensure the panel/drawer animation has started
|
|
const timer = setTimeout(() => {
|
|
textareaRef.current?.focus();
|
|
}, 100);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const handleSuggestionClick = async (text: string) => {
|
|
if (status === 'streaming' || status === 'submitted') {
|
|
return;
|
|
}
|
|
setLocalPrompt('');
|
|
setPrompt('');
|
|
await sendMessage({ text });
|
|
};
|
|
|
|
const handleSubmit: PromptInputProps['onSubmit'] = async (message, event) => {
|
|
event.preventDefault();
|
|
|
|
if (status === 'streaming' || status === 'submitted') {
|
|
return;
|
|
}
|
|
|
|
const { text } = message;
|
|
|
|
if (!text) {
|
|
return;
|
|
}
|
|
|
|
setLocalPrompt('');
|
|
setPrompt('');
|
|
await sendMessage({ text });
|
|
};
|
|
|
|
const handleClearChat = async () => {
|
|
try {
|
|
await clearMessages();
|
|
setMessages([]);
|
|
toast.success('Chat history cleared');
|
|
} catch (error) {
|
|
toast.error('Failed to clear chat history', {
|
|
description: error instanceof Error ? error.message : 'Unknown error',
|
|
});
|
|
}
|
|
};
|
|
|
|
// Show loading state while initial messages are being loaded
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex size-full w-full flex-col items-center justify-center overflow-hidden whitespace-nowrap rounded-xl xl:max-w-md xl:border xl:bg-background">
|
|
<Spinner />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex size-full w-full flex-col overflow-hidden whitespace-nowrap bg-background">
|
|
<div className="flex items-center justify-between px-4 py-2.5">
|
|
<h2 className="font-semibold text-sm">Chat</h2>
|
|
<div className="flex items-center gap-3">
|
|
<ButtonGroup orientation="horizontal">
|
|
<CopyChat messages={messages} />
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
disabled={messages.length === 0}
|
|
onClick={handleClearChat}
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
>
|
|
<Trash className="size-3.5" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Clear chat</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
onClick={() => setIsOpen(false)}
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
>
|
|
<ChevronRightIcon className="size-3.5" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Close chat</TooltipContent>
|
|
</Tooltip>
|
|
</ButtonGroup>
|
|
</div>
|
|
</div>
|
|
|
|
<Conversation>
|
|
<ConversationContent>
|
|
{messages.map((message) => (
|
|
<Message
|
|
className="max-w-[90%]"
|
|
from={message.role}
|
|
key={message.id}
|
|
>
|
|
<MessageMetadata
|
|
inProgress={status === 'submitted' || status === 'streaming'}
|
|
parts={message.parts as MyUIMessage['parts']}
|
|
/>
|
|
{message.parts
|
|
.filter((part) => part.type === 'text')
|
|
.map((part) => (
|
|
<MessageContent
|
|
key={`${message.id}-${part.type}-${part.text}`}
|
|
>
|
|
<MessageResponse className="text-wrap">
|
|
{part.text}
|
|
</MessageResponse>
|
|
</MessageContent>
|
|
))}
|
|
</Message>
|
|
))}
|
|
{status === 'submitted' && (
|
|
<div className="size-12 text-muted-foreground text-sm">
|
|
<Spinner />
|
|
</div>
|
|
)}
|
|
</ConversationContent>
|
|
<ConversationScrollButton className="border-none bg-foreground text-background hover:bg-foreground/80 hover:text-background" />
|
|
</Conversation>
|
|
|
|
<div className="relative grid w-auto shrink-0 gap-4 p-4">
|
|
{!messages.length && (
|
|
<>
|
|
<Suggestions className="flex-col items-start gap-px">
|
|
{suggestions.map((text) => (
|
|
<Suggestion
|
|
className="rounded-none p-0"
|
|
key={text}
|
|
onClick={handleSuggestionClick}
|
|
suggestion={text}
|
|
variant="link"
|
|
/>
|
|
))}
|
|
</Suggestions>
|
|
<p className="text-muted-foreground text-sm">
|
|
Tip: You can open and close chat with{' '}
|
|
<KbdGroup>
|
|
<Kbd className="border bg-transparent">⌘</Kbd>
|
|
<Kbd className="border bg-transparent">I</Kbd>
|
|
</KbdGroup>
|
|
</p>
|
|
</>
|
|
)}
|
|
<PromptInputProvider initialInput={localPrompt} key={providerKey}>
|
|
<PromptInput onSubmit={handleSubmit}>
|
|
<PromptInputBody>
|
|
<PromptInputTextarea
|
|
maxLength={1000}
|
|
onChange={(e) => {
|
|
setLocalPrompt(e.target.value);
|
|
setPrompt(e.target.value);
|
|
}}
|
|
ref={textareaRef}
|
|
/>
|
|
</PromptInputBody>
|
|
<PromptInputFooter>
|
|
<p className="text-muted-foreground text-xs">
|
|
{localPrompt.length} / 1000
|
|
</p>
|
|
<PromptInputSubmit
|
|
onClick={
|
|
status === 'streaming'
|
|
? (e) => {
|
|
e.preventDefault();
|
|
stop();
|
|
}
|
|
: undefined
|
|
}
|
|
status={status}
|
|
/>
|
|
</PromptInputFooter>
|
|
</PromptInput>
|
|
</PromptInputProvider>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const Chat = ({ basePath, suggestions }: ChatProps) => {
|
|
const { isOpen, setIsOpen } = useChatContext();
|
|
const isMobile = useIsMobile();
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
// Check for Meta (⌘ on Mac, Windows key on Windows) + "i" (ignore case)
|
|
if (
|
|
(event.metaKey || event.ctrlKey) &&
|
|
!event.altKey &&
|
|
!event.shiftKey &&
|
|
event.key.toLowerCase() === 'i'
|
|
) {
|
|
event.preventDefault();
|
|
|
|
setIsOpen((prev) => !prev);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
|
|
return () => {
|
|
window.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
}, [setIsOpen]);
|
|
|
|
return (
|
|
<>
|
|
<Button
|
|
className="hidden shrink-0 shadow-none md:flex"
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
size="sm"
|
|
variant="outline"
|
|
>
|
|
Ask AI
|
|
</Button>
|
|
|
|
<Portal.Root className="hidden md:block">
|
|
<div
|
|
className={cn(
|
|
'fixed z-50 flex flex-col gap-4 bg-background transition-all',
|
|
'inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
|
'translate-x-full data-[state=open]:translate-x-0'
|
|
)}
|
|
data-state={isOpen ? 'open' : 'closed'}
|
|
>
|
|
<ChatInner
|
|
basePath={basePath}
|
|
isOpen={isOpen}
|
|
suggestions={suggestions}
|
|
/>
|
|
</div>
|
|
</Portal.Root>
|
|
<div className="md:hidden">
|
|
<Drawer
|
|
onOpenChange={isMobile ? setIsOpen : undefined}
|
|
open={isMobile ? isOpen : false}
|
|
>
|
|
<DrawerTrigger asChild>
|
|
<Button className="shadow-none" size="sm" variant="outline">
|
|
Ask AI
|
|
</Button>
|
|
</DrawerTrigger>
|
|
<DrawerContent className="h-[80dvh]">
|
|
<ChatInner
|
|
basePath={basePath}
|
|
isOpen={isOpen}
|
|
suggestions={suggestions}
|
|
/>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|