mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-02-05 08:23:08 +00:00
- Create shared `isSubmitEnter()` utility in `ui/src/lib/keyboard.ts` for IME-aware Enter key handling across all input components - Extract magic number 48 to named constant `COLLAPSED_DEBUG_PANEL_CLEARANCE` with explanatory comment (40px panel header + 8px margin) - Update 5 components to use the new utility: - AssistantChat.tsx - ExpandProjectChat.tsx - SpecCreationChat.tsx - FolderBrowser.tsx - TerminalTabs.tsx This follows up on PR #121 which added IME composition checks. The refactoring centralizes the logic for easier maintenance and documents the padding value that prevents Kanban cards from being cut off when the debug panel is collapsed. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
303 lines
10 KiB
TypeScript
303 lines
10 KiB
TypeScript
/**
|
|
* Assistant Chat Component
|
|
*
|
|
* Main chat interface for the project assistant.
|
|
* Displays messages and handles user input.
|
|
* Supports conversation history with resume functionality.
|
|
*/
|
|
|
|
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
|
import { Send, Loader2, Wifi, WifiOff, Plus, History } from 'lucide-react'
|
|
import { useAssistantChat } from '../hooks/useAssistantChat'
|
|
import { ChatMessage as ChatMessageComponent } from './ChatMessage'
|
|
import { ConversationHistory } from './ConversationHistory'
|
|
import type { ChatMessage } from '../lib/types'
|
|
import { isSubmitEnter } from '../lib/keyboard'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
|
|
interface AssistantChatProps {
|
|
projectName: string
|
|
conversationId?: number | null
|
|
initialMessages?: ChatMessage[]
|
|
isLoadingConversation?: boolean
|
|
onNewChat?: () => void
|
|
onSelectConversation?: (id: number) => void
|
|
onConversationCreated?: (id: number) => void
|
|
}
|
|
|
|
export function AssistantChat({
|
|
projectName,
|
|
conversationId,
|
|
initialMessages,
|
|
isLoadingConversation,
|
|
onNewChat,
|
|
onSelectConversation,
|
|
onConversationCreated,
|
|
}: AssistantChatProps) {
|
|
const [inputValue, setInputValue] = useState('')
|
|
const [showHistory, setShowHistory] = useState(false)
|
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
|
const hasStartedRef = useRef(false)
|
|
const lastConversationIdRef = useRef<number | null | undefined>(undefined)
|
|
|
|
// Memoize the error handler to prevent infinite re-renders
|
|
const handleError = useCallback((error: string) => {
|
|
console.error('Assistant error:', error)
|
|
}, [])
|
|
|
|
const {
|
|
messages,
|
|
isLoading,
|
|
connectionStatus,
|
|
conversationId: activeConversationId,
|
|
start,
|
|
sendMessage,
|
|
clearMessages,
|
|
} = useAssistantChat({
|
|
projectName,
|
|
onError: handleError,
|
|
})
|
|
|
|
// Notify parent when a NEW conversation is created (not when switching to existing)
|
|
// Track activeConversationId to fire callback only once when it transitions from null to a value
|
|
const previousActiveConversationIdRef = useRef<number | null>(activeConversationId)
|
|
useEffect(() => {
|
|
const hadNoConversation = previousActiveConversationIdRef.current === null
|
|
const nowHasConversation = activeConversationId !== null
|
|
|
|
if (hadNoConversation && nowHasConversation && onConversationCreated) {
|
|
onConversationCreated(activeConversationId)
|
|
}
|
|
|
|
previousActiveConversationIdRef.current = activeConversationId
|
|
}, [activeConversationId, onConversationCreated])
|
|
|
|
// Auto-scroll to bottom on new messages
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
}, [messages])
|
|
|
|
// Start or resume the chat session when component mounts or conversationId changes
|
|
useEffect(() => {
|
|
// Skip if we're loading conversation details
|
|
if (isLoadingConversation) {
|
|
return
|
|
}
|
|
|
|
// Only start if conversationId has actually changed
|
|
if (lastConversationIdRef.current === conversationId && hasStartedRef.current) {
|
|
return
|
|
}
|
|
|
|
// Check if we're switching to a different conversation (not initial mount)
|
|
const isSwitching = lastConversationIdRef.current !== undefined &&
|
|
lastConversationIdRef.current !== conversationId
|
|
|
|
lastConversationIdRef.current = conversationId
|
|
hasStartedRef.current = true
|
|
|
|
// Clear existing messages when switching conversations
|
|
if (isSwitching) {
|
|
clearMessages()
|
|
}
|
|
|
|
// Start the session with the conversation ID (or null for new)
|
|
start(conversationId)
|
|
}, [conversationId, isLoadingConversation, start, clearMessages])
|
|
|
|
// Handle starting a new chat
|
|
const handleNewChat = useCallback(() => {
|
|
clearMessages()
|
|
onNewChat?.()
|
|
}, [clearMessages, onNewChat])
|
|
|
|
// Handle selecting a conversation from history
|
|
const handleSelectConversation = useCallback((id: number) => {
|
|
setShowHistory(false)
|
|
onSelectConversation?.(id)
|
|
}, [onSelectConversation])
|
|
|
|
// Focus input when not loading
|
|
useEffect(() => {
|
|
if (!isLoading) {
|
|
inputRef.current?.focus()
|
|
}
|
|
}, [isLoading])
|
|
|
|
const handleSend = () => {
|
|
const content = inputValue.trim()
|
|
if (!content || isLoading || isLoadingConversation) return
|
|
|
|
sendMessage(content)
|
|
setInputValue('')
|
|
}
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (isSubmitEnter(e)) {
|
|
e.preventDefault()
|
|
handleSend()
|
|
}
|
|
}
|
|
|
|
// Combine initial messages (from resumed conversation) with live messages
|
|
// Merge both arrays with deduplication by message ID to prevent history loss
|
|
const displayMessages = useMemo(() => {
|
|
const isConversationSynced = lastConversationIdRef.current === conversationId && !isLoadingConversation
|
|
|
|
// If not synced yet, show only initialMessages (or empty)
|
|
if (!isConversationSynced) {
|
|
return initialMessages ?? []
|
|
}
|
|
|
|
// If no initial messages, just show live messages
|
|
if (!initialMessages || initialMessages.length === 0) {
|
|
return messages
|
|
}
|
|
|
|
// Merge both arrays, deduplicating by ID (live messages take precedence)
|
|
const messageMap = new Map<string, ChatMessage>()
|
|
for (const msg of initialMessages) {
|
|
messageMap.set(msg.id, msg)
|
|
}
|
|
for (const msg of messages) {
|
|
messageMap.set(msg.id, msg)
|
|
}
|
|
return Array.from(messageMap.values())
|
|
}, [initialMessages, messages, conversationId, isLoadingConversation])
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Header with actions and connection status */}
|
|
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-background">
|
|
{/* Action buttons */}
|
|
<div className="flex items-center gap-1 relative">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleNewChat}
|
|
className="h-8 w-8"
|
|
title="New conversation"
|
|
disabled={isLoading}
|
|
>
|
|
<Plus size={16} />
|
|
</Button>
|
|
<Button
|
|
variant={showHistory ? 'secondary' : 'ghost'}
|
|
size="icon"
|
|
onClick={() => setShowHistory(!showHistory)}
|
|
className="h-8 w-8"
|
|
title="Conversation history"
|
|
>
|
|
<History size={16} />
|
|
</Button>
|
|
|
|
{/* History dropdown */}
|
|
<ConversationHistory
|
|
projectName={projectName}
|
|
currentConversationId={conversationId ?? activeConversationId}
|
|
isOpen={showHistory}
|
|
onClose={() => setShowHistory(false)}
|
|
onSelectConversation={handleSelectConversation}
|
|
/>
|
|
</div>
|
|
|
|
{/* Connection status */}
|
|
<div className="flex items-center gap-2">
|
|
{connectionStatus === 'connected' ? (
|
|
<>
|
|
<Wifi size={14} className="text-green-500" />
|
|
<span className="text-xs text-muted-foreground">Connected</span>
|
|
</>
|
|
) : connectionStatus === 'connecting' ? (
|
|
<>
|
|
<Loader2 size={14} className="text-primary animate-spin" />
|
|
<span className="text-xs text-muted-foreground">Connecting...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<WifiOff size={14} className="text-destructive" />
|
|
<span className="text-xs text-muted-foreground">Disconnected</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Messages area */}
|
|
<div className="flex-1 overflow-y-auto bg-background">
|
|
{isLoadingConversation ? (
|
|
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<Loader2 size={16} className="animate-spin" />
|
|
<span>Loading conversation...</span>
|
|
</div>
|
|
</div>
|
|
) : displayMessages.length === 0 ? (
|
|
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
|
{isLoading ? (
|
|
<div className="flex items-center gap-2">
|
|
<Loader2 size={16} className="animate-spin" />
|
|
<span>Connecting to assistant...</span>
|
|
</div>
|
|
) : (
|
|
<span>Ask me anything about the codebase</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="py-4">
|
|
{displayMessages.map((message) => (
|
|
<ChatMessageComponent key={message.id} message={message} />
|
|
))}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Loading indicator */}
|
|
{isLoading && displayMessages.length > 0 && (
|
|
<div className="px-4 py-2 border-t border-border bg-background">
|
|
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
|
<div className="flex gap-1">
|
|
<span className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
|
<span className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
|
<span className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
|
</div>
|
|
<span>Thinking...</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Input area */}
|
|
<div className="border-t border-border p-4 bg-card">
|
|
<div className="flex gap-2">
|
|
<Textarea
|
|
ref={inputRef}
|
|
value={inputValue}
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Ask about the codebase..."
|
|
disabled={isLoading || isLoadingConversation || connectionStatus !== 'connected'}
|
|
className="flex-1 resize-none min-h-[44px] max-h-[120px]"
|
|
rows={1}
|
|
/>
|
|
<Button
|
|
onClick={handleSend}
|
|
disabled={!inputValue.trim() || isLoading || isLoadingConversation || connectionStatus !== 'connected'}
|
|
title="Send message"
|
|
>
|
|
{isLoading ? (
|
|
<Loader2 size={18} className="animate-spin" />
|
|
) : (
|
|
<Send size={18} />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-2">
|
|
Press Enter to send, Shift+Enter for new line
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|