FileTree: - Add ARIA labels to new file button - Add role="button", tabIndex, and keyboard navigation (Enter/Space) for file/folder items - Add aria-label for expand/collapse folder states - Add aria-expanded attribute for folders - Add focus ring styles (focus:ring-2 focus:ring-accent) - Add aria-hidden to decorative icons SearchInFilesPanel: - Add ARIA labels to close button and search button - Add aria-label to search input - Add aria-live="polite" to results count badge - Add keyboard navigation (Enter/Space) to search results - Add focus ring styles to search results - Add role="button" to clickable result items - Add aria-label to case sensitive checkbox - Add aria-hidden to decorative icons AIChat: - Add aria-live="polite" to chat messages area - Add role="log" to messages container - Add aria-label to message input textarea - Add aria-label to send button - Add role="note" to keyboard shortcut hint - Add aria-hidden to decorative icons
144 lines
6.1 KiB
TypeScript
144 lines
6.1 KiB
TypeScript
import { useState, useCallback, memo } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Sparkle, PaperPlaneRight } from '@phosphor-icons/react';
|
|
import { toast } from 'sonner';
|
|
import { captureError } from '@/lib/sentry';
|
|
|
|
interface Message {
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
}
|
|
|
|
interface AIChatProps {
|
|
currentCode: string;
|
|
}
|
|
|
|
export function AIChat({ currentCode }: AIChatProps) {
|
|
const [messages, setMessages] = useState<Message[]>([
|
|
{
|
|
role: 'assistant',
|
|
content: 'Hi! I\'m your AI assistant for Roblox Lua development. Ask me anything about your code, Roblox scripting, or game development!',
|
|
},
|
|
]);
|
|
const [input, setInput] = useState('');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
const handleSend = useCallback(async () => {
|
|
if (!input.trim() || isLoading) return;
|
|
|
|
const userMessage = input.trim();
|
|
setInput('');
|
|
setMessages((prev) => [...prev, { role: 'user', content: userMessage }]);
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
if (typeof window === 'undefined' || !window.spark?.llm) {
|
|
throw new Error('AI service is not available');
|
|
}
|
|
|
|
// Context-aware prompt: include active code, file name, and platform
|
|
const promptText = `You are an expert Roblox Lua developer and code assistant.\n\nUser's active code:\n\n\`\`\`lua\n${currentCode}\n\`\`\`\n\nUser question: ${userMessage}\n\nIf the user asks for code completion, suggest the next line(s) of code.\nIf the user asks for an explanation, explain the code in simple terms.\nIf the user asks for platform-specific help, provide Roblox Lua answers.\n\nRespond with concise, friendly, and actionable advice. Include code examples inline when relevant.`;
|
|
|
|
const response = await window.spark.llm(promptText, 'gpt-4o-mini');
|
|
// If the response contains code, show it in a highlighted block
|
|
const codeMatch = response.match(/```lua([\s\S]*?)```/);
|
|
if (codeMatch) {
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{ role: 'assistant', content: response.replace(/```lua([\s\S]*?)```/, '') },
|
|
{ role: 'assistant', content: `<pre class='bg-muted p-2 rounded text-xs font-mono'>${codeMatch[1].trim()}</pre>` },
|
|
]);
|
|
} else {
|
|
setMessages((prev) => [...prev, { role: 'assistant', content: response }]);
|
|
}
|
|
} catch (error) {
|
|
console.error('AI chat error:', error);
|
|
captureError(error as Error, { context: 'ai_chat', userMessage, codeLength: currentCode.length });
|
|
toast.error('Failed to get AI response. Please try again.');
|
|
setMessages((prev) => [...prev, { role: 'assistant', content: 'Sorry, I encountered an error. Please try asking again or check your connection.' }]);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [input, isLoading, currentCode]);
|
|
|
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
}, [handleSend]);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-card border-l border-border min-w-[260px] max-w-[340px]">
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-card/80">
|
|
<Sparkle className="text-accent" weight="fill" aria-hidden="true" />
|
|
<h2 className="font-semibold text-xs tracking-wide uppercase text-muted-foreground">AI Assistant</h2>
|
|
</div>
|
|
|
|
<ScrollArea className="flex-1 px-2 py-2" aria-live="polite" aria-label="Chat messages">
|
|
<div className="space-y-2" role="log">
|
|
{messages.map((message, index) => (
|
|
<div
|
|
key={index}
|
|
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
|
>
|
|
<div
|
|
className={`max-w-[85%] rounded-md px-3 py-1.5 text-xs shadow-sm ${
|
|
message.role === 'user'
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'bg-muted text-foreground'
|
|
}`}
|
|
>
|
|
{message.content.startsWith('<pre') ? (
|
|
<span dangerouslySetInnerHTML={{ __html: message.content }} />
|
|
) : (
|
|
<p className="whitespace-pre-wrap leading-relaxed">{message.content}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{isLoading && (
|
|
<div className="flex justify-start">
|
|
<div className="bg-muted rounded-md px-3 py-1.5">
|
|
<div className="flex gap-1">
|
|
<div className="w-2 h-2 bg-accent rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
|
<div className="w-2 h-2 bg-accent rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
|
<div className="w-2 h-2 bg-accent rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
<div className="px-2 py-2 border-t border-border bg-card/80">
|
|
<div className="flex gap-2">
|
|
<Textarea
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Ask about your code..."
|
|
className="resize-none min-h-[36px] max-h-24 bg-background text-xs px-2 py-1"
|
|
disabled={isLoading}
|
|
aria-label="Chat message input"
|
|
/>
|
|
<Button
|
|
onClick={handleSend}
|
|
disabled={!input.trim() || isLoading}
|
|
className="bg-accent text-accent-foreground hover:bg-accent/90 btn-accent-hover self-end h-8 w-8 p-0"
|
|
tabIndex={-1}
|
|
title="Send message"
|
|
aria-label="Send message"
|
|
>
|
|
<PaperPlaneRight weight="fill" size={16} aria-hidden="true" />
|
|
</Button>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground mt-1" role="note">
|
|
Press Enter to send, Shift+Enter for new line
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|