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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,12 +119,21 @@ export function AppShell({ children }: AppShellProps) {
|
||||
|
||||
// Curtain is disabled on home page and on /projects without a selected project
|
||||
const searchObj = routerState.location.search as Record<string, unknown>;
|
||||
const projectId = typeof searchObj['projectId'] === 'string' ? searchObj['projectId'] : undefined;
|
||||
const curtainEnabled =
|
||||
currentPath !== '/' &&
|
||||
!(currentPath === '/projects' && !searchObj['projectId']);
|
||||
!(currentPath === '/projects' && !projectId);
|
||||
const curtainEnabledRef = useRef(curtainEnabled);
|
||||
curtainEnabledRef.current = curtainEnabled;
|
||||
|
||||
// Derive AI chat context from current route
|
||||
const isProjectView = currentPath === '/projects' && !!projectId;
|
||||
const contextType = isProjectView ? 'project' as const : 'global' as const;
|
||||
const projectQuery = trpc.projects.get.useQuery(
|
||||
{ id: projectId ?? '' },
|
||||
{ enabled: !!projectId },
|
||||
);
|
||||
|
||||
// --- Curtain animation state ---
|
||||
const [curtainOpen, setCurtainOpen] = useState(false);
|
||||
const curtainOpenRef = useRef(false);
|
||||
@@ -149,6 +158,17 @@ export function AppShell({ children }: AppShellProps) {
|
||||
else openCurtain();
|
||||
}, [openCurtain, closeCurtain]);
|
||||
|
||||
// Keep curtain position in sync with window height on resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (curtainOpenRef.current) {
|
||||
y.set(window.innerHeight);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [y]);
|
||||
|
||||
// Keyboard shortcut: Cmd/Ctrl+K
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -191,10 +211,17 @@ export function AppShell({ children }: AppShellProps) {
|
||||
<AppSidebar
|
||||
currentPath={currentPath}
|
||||
setTokenDialogOpen={setTokenDialogOpen}
|
||||
onNavClick={closeCurtain}
|
||||
/>
|
||||
<SidebarInset className="overflow-hidden">
|
||||
{/* AI Chat layer: always mounted behind the content panel */}
|
||||
<AIChatPanel onOpenSettings={() => setTokenDialogOpen(true)} />
|
||||
<AIChatPanel
|
||||
onOpenSettings={() => setTokenDialogOpen(true)}
|
||||
contextType={contextType}
|
||||
projectId={projectId}
|
||||
projectName={projectQuery.data?.name}
|
||||
curtainOpen={curtainOpen}
|
||||
/>
|
||||
|
||||
{/* Content panel: slides down to reveal chat */}
|
||||
<motion.div
|
||||
@@ -273,9 +300,10 @@ export function AppShell({ children }: AppShellProps) {
|
||||
interface AppSidebarProps {
|
||||
currentPath: string;
|
||||
setTokenDialogOpen: (open: boolean) => void;
|
||||
onNavClick: () => void;
|
||||
}
|
||||
|
||||
function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
|
||||
function AppSidebar({ currentPath, setTokenDialogOpen, onNavClick }: AppSidebarProps) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
@@ -328,7 +356,7 @@ function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
|
||||
isActive={isActive}
|
||||
tooltip={label}
|
||||
>
|
||||
<Link to={to}>
|
||||
<Link to={to} onClick={onNavClick}>
|
||||
<Icon />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
@@ -416,7 +417,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
</div>
|
||||
|
||||
{/* Project tree */}
|
||||
<div className="flex-1 overflow-y-auto py-1 px-1">
|
||||
<ScrollArea className="flex-1 py-1 px-1">
|
||||
{totalProjects === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
@@ -824,7 +825,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Rename project dialog */}
|
||||
<Dialog
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TaskItem } from './TaskRow';
|
||||
|
||||
@@ -275,7 +276,8 @@ export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-2" align="start">
|
||||
{knownAssignees.length > 0 && (
|
||||
<div className="max-h-36 overflow-y-auto flex flex-col gap-0.5 mb-2">
|
||||
<ScrollArea className="max-h-36 mb-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{knownAssignees.map((name) => (
|
||||
<Button
|
||||
key={name}
|
||||
@@ -293,7 +295,8 @@ export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps
|
||||
<span className="truncate">{name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
{knownAssignees.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground px-2 py-1 mb-2">No existing assignees</p>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const NO_CLIENT = '__no_client__';
|
||||
@@ -512,7 +513,8 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
|
||||
<PopoverContent className="w-64 p-2" align="start">
|
||||
{/* Known assignees list */}
|
||||
{knownAssignees.length > 0 && (
|
||||
<div className="max-h-36 overflow-y-auto flex flex-col gap-0.5 mb-2">
|
||||
<ScrollArea className="max-h-36 mb-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{knownAssignees.map((name) => (
|
||||
<Button
|
||||
key={name}
|
||||
@@ -530,7 +532,8 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
|
||||
<span className="truncate">{name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
{knownAssignees.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground px-2 py-1 mb-2">No existing assignees</p>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { PriorityBadge } from './PriorityBadge';
|
||||
import { parseAssignees, type TaskItem } from './TaskRow';
|
||||
@@ -194,7 +195,8 @@ export function TaskDetailDialog({ task, open, onOpenChange, onEdit, onDelete }:
|
||||
|
||||
<TabsContent value="comment" className="px-6 py-4 min-h-[120px] flex flex-col gap-4">
|
||||
{/* Comment list */}
|
||||
<div className="flex flex-col gap-4 max-h-[260px] overflow-y-auto">
|
||||
<ScrollArea className="max-h-[260px]">
|
||||
<div className="flex flex-col gap-4">
|
||||
{(!comments || comments.length === 0) ? (
|
||||
<p className="text-sm text-muted-foreground italic">No comments yet.</p>
|
||||
) : (
|
||||
@@ -223,6 +225,7 @@ export function TaskDetailDialog({ task, open, onOpenChange, onEdit, onDelete }:
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Add comment input */}
|
||||
<form
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AddCheckpointDialogProps {
|
||||
@@ -85,15 +86,17 @@ export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: Ad
|
||||
|
||||
{/* Just-added list */}
|
||||
{added.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5 max-h-32 overflow-y-auto">
|
||||
{added.map((entry, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Check className="h-3.5 w-3.5 text-chart-2 shrink-0" />
|
||||
<span className="truncate">{entry.title}</span>
|
||||
<span className="ml-auto text-xs shrink-0">{format(entry.date, 'MMM d')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ScrollArea className="max-h-32">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{added.map((entry, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Check className="h-3.5 w-3.5 text-chart-2 shrink-0" />
|
||||
<span className="truncate">{entry.title}</span>
|
||||
<span className="ml-auto text-xs shrink-0">{format(entry.date, 'MMM d')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
|
||||
65
src/renderer/components/ui/scroll-area.tsx
Normal file
65
src/renderer/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
viewportRef,
|
||||
viewportClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
|
||||
viewportRef?: React.Ref<HTMLDivElement>;
|
||||
viewportClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={viewportRef}
|
||||
data-slot="scroll-area-viewport"
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1",
|
||||
viewportClassName
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
Reference in New Issue
Block a user