feat: implement full context-scoped AI chat UI in AIChatPanel

- Added AIChatPanel component with context header, user and AI message handling.
- Integrated streaming responses via IPC and error handling for chat mutations.
- Enhanced user experience with input handling and auto-scrolling features.
- Updated AppShell to derive AI chat context from the current route.
- Introduced ScrollArea component for better scrolling behavior in various dialogs.
- Added support for Tailwind typography and improved global styles.
- Updated project and task dialogs to utilize ScrollArea for better UX.
This commit is contained in:
Roberto Musso
2026-02-24 12:02:06 +01:00
parent 00a43e0fbc
commit 5eb19e022e
20 changed files with 962 additions and 91 deletions

View File

@@ -1,15 +1,154 @@
import { Sparkles, KeyRound } from 'lucide-react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { Sparkles, KeyRound, ArrowUp } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { trpc } from '@/lib/trpc';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
error?: boolean;
}
interface AIChatPanelProps {
onOpenSettings?: () => void;
contextType: 'global' | 'project';
projectId?: string;
projectName?: string;
curtainOpen: boolean;
}
export function AIChatPanel({ onOpenSettings }: AIChatPanelProps) {
export function AIChatPanel({
onOpenSettings,
contextType,
projectId,
projectName,
curtainOpen,
}: AIChatPanelProps) {
const hasTokenQuery = trpc.ai.hasToken.useQuery();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
const streamingContentRef = useRef('');
const chatMutation = trpc.ai.chat.useMutation();
const scrollToBottom = useCallback(() => {
const el = messagesContainerRef.current;
if (el) el.scrollTo({ top: el.scrollHeight });
}, []);
// Reset input when curtain closes; scroll to bottom when it reopens
useEffect(() => {
if (!curtainOpen) {
setInput('');
} else {
setTimeout(scrollToBottom, 50);
}
}, [curtainOpen, scrollToBottom]);
// Auto-scroll when messages change or streaming content updates
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent, scrollToBottom]);
const handleSend = useCallback(() => {
const trimmed = input.trim();
if (!trimmed || isStreaming) return;
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content: trimmed,
};
setMessages((prev) => [...prev, userMsg]);
setInput('');
setIsStreaming(true);
setStreamingContent('');
streamingContentRef.current = '';
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
if (done) {
const finalContent = streamingContentRef.current;
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: finalContent },
]);
setStreamingContent('');
streamingContentRef.current = '';
setIsStreaming(false);
unsubscribe();
return;
}
streamingContentRef.current += token;
setStreamingContent(streamingContentRef.current);
});
chatMutation.mutate(
{
message: trimmed,
context: {
type: contextType,
...(contextType === 'project' && projectId ? { projectId } : {}),
},
},
{
onSuccess: (data) => {
if (data.error) {
unsubscribe();
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: data.error!, error: true },
]);
setStreamingContent('');
streamingContentRef.current = '';
setIsStreaming(false);
}
},
onError: (err) => {
unsubscribe();
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: err.message || 'An unexpected error occurred.', error: true },
]);
setStreamingContent('');
streamingContentRef.current = '';
setIsStreaming(false);
},
},
);
}, [input, isStreaming, contextType, projectId, chatMutation]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// Smart wheel handler: only stop propagation when there's content to scroll through
const handleWheel = useCallback((e: React.WheelEvent) => {
const el = messagesContainerRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 2;
const atTop = el.scrollTop < 2;
// Let event propagate to AppShell when at boundaries
if ((e.deltaY > 0 && atBottom) || (e.deltaY < 0 && atTop)) return;
e.stopPropagation();
}, []);
// No token configured — show settings prompt
if (hasTokenQuery.data === false) {
return (
<div className="absolute inset-0 z-0 flex flex-col items-center justify-center bg-background">
@@ -19,7 +158,8 @@ export function AIChatPanel({ onOpenSettings }: AIChatPanelProps) {
<div className="text-center space-y-1">
<p className="text-sm font-medium">AI provider not configured</p>
<p className="text-xs text-muted-foreground">
Connect your GitHub Copilot token to enable AI-powered features like chat, summaries, and suggestions.
Connect your GitHub Copilot token to enable AI-powered features
like chat, summaries, and suggestions.
</p>
</div>
<Button variant="outline" size="sm" onClick={onOpenSettings}>
@@ -31,12 +171,173 @@ export function AIChatPanel({ onOpenSettings }: AIChatPanelProps) {
);
}
const hasMessages = messages.length > 0 || isStreaming;
const contextLabel =
contextType === 'project' && projectName
? `Chatting about: ${projectName}`
: 'Global workspace';
return (
<div className="absolute inset-0 z-0 flex flex-col items-center justify-center bg-background">
<Sparkles size={32} className="text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground/60 tracking-wide">
AI Chat coming soon
</p>
<div className="absolute inset-0 z-0 flex flex-col bg-background">
{/* Context header */}
<div className="flex items-center gap-2 px-6 pt-4 pb-2">
<Badge variant="outline">{contextLabel}</Badge>
</div>
{/* Scrollable messages area */}
<ScrollArea
className="flex-1 min-h-0"
viewportRef={messagesContainerRef}
viewportClassName="[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end"
onWheel={handleWheel}
>
{/* Messages */}
{hasMessages && (
<div className="mx-auto w-full max-w-[1088px] px-6 pt-4 pb-44">
<div className="flex flex-col gap-4">
{messages.map((msg) => {
if (msg.role === 'user') {
return (
<div key={msg.id} className="flex justify-end">
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
<ChatMarkdown content={msg.content} />
</div>
</div>
);
}
if (msg.error) {
return (
<div key={msg.id} className="mr-auto max-w-[75%]">
<p className="text-sm text-destructive whitespace-pre-wrap">
{msg.content}
</p>
</div>
);
}
return (
<div key={msg.id} className="mr-auto max-w-[75%]">
<div className="flex items-center gap-1.5 mb-1">
<Sparkles size={16} className="text-foreground" />
<span className="text-sm font-semibold">Adiuva</span>
</div>
<div className="pl-[22px]">
<ChatMarkdown content={msg.content} />
</div>
</div>
);
})}
{/* Streaming AI response */}
{isStreaming && (
<div className="mr-auto max-w-[75%]">
<div className="flex items-center gap-1.5 mb-1">
<Sparkles size={16} className="text-foreground" />
<span className="text-sm font-semibold">Adiuva</span>
</div>
{streamingContent ? (
<div className="pl-[22px]">
<ChatMarkdown content={streamingContent} />
</div>
) : (
<div className="space-y-2 pl-[22px]">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-32" />
</div>
)}
</div>
)}
</div>
</div>
)}
</ScrollArea>
{/* Fixed input — pinned to the bottom */}
<div className="absolute bottom-0 left-0 right-0 z-10 px-6 pb-4 pt-12 pointer-events-none">
<div className="absolute inset-x-0 top-0 h-full bg-gradient-to-b from-transparent via-background/80 to-background" />
<div className="relative pointer-events-auto mx-auto max-w-[1088px]">
<ChatInput
input={input}
isStreaming={isStreaming}
onInputChange={setInput}
onKeyDown={handleKeyDown}
onSend={handleSend}
/>
</div>
</div>
</div>
);
}
/* ---------- ChatInput: Floating glass card ---------- */
interface ChatInputProps {
input: string;
isStreaming: boolean;
onInputChange: (value: string) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onSend: () => void;
}
function ChatInput({
input,
isStreaming,
onInputChange,
onKeyDown,
onSend,
}: ChatInputProps) {
return (
<div className="relative rounded-2xl bg-muted/60 backdrop-blur-xl border border-border shadow-[0_2px_20px_rgba(0,0,0,0.08)] dark:shadow-[0_2px_20px_rgba(0,0,0,0.3)] overflow-hidden">
<textarea
value={input}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Ask me anything..."
rows={3}
className="w-full resize-none bg-transparent px-4 pt-4 pb-12 text-sm placeholder:text-muted-foreground outline-none"
/>
<div className="absolute bottom-3 right-3">
<button
onClick={onSend}
disabled={!input.trim() || isStreaming}
className="flex h-8 w-8 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
>
<ArrowUp size={16} />
</button>
</div>
</div>
);
}
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
function ChatMarkdown({ content }: { content: string }) {
return (
<div className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
pre: ({ children }) => (
<pre className="bg-muted rounded-lg p-3 overflow-x-auto text-xs">
{children}
</pre>
),
code: ({ children, className }) => {
if (!className) {
return (
<code className="bg-muted rounded px-1.5 py-0.5 text-xs font-mono">
{children}
</code>
);
}
return <code className={className}>{children}</code>;
},
}}
>
{content}
</ReactMarkdown>
</div>
);
}