Files
nexusAI/packages/chat-client/src/components/ChatWindow.jsx
2026-04-21 02:42:18 -07:00

206 lines
6.2 KiB
JavaScript

import React, { useEffect, useRef } from 'react';
import MessageBubble from './MessageBubble';
export default function ChatWindow({
messages,
loadingHistory,
streaming,
onSendMessage,
onCancel,
activeSession,
onTogglePanel,
onBack,
canGoBack,
loadedModel,
summarising,
}) {
const bottomRef = useRef(null);
const inputRef = useRef(null);
const [input, setInput] = React.useState('');
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
function handleSend() {
const text = input.trim();
if (!text || streaming) return;
setInput('');
onSendMessage(text);
}
function handleKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
// Trim .gguf for display
const modelLabel = loadedModel ? loadedModel.replace('.gguf', '') : null;
return (
<div className="flex-col flex-1 overflow-hidden" style={{ background: 'var(--bg-base)' }}>
{/* Header */}
<div className="panel-header" style={{ padding: '0 12px 0 8px', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', minWidth: 0 }}>
{/* Back button */}
{canGoBack && (
<button
className="btn-icon"
onClick={onBack}
title="Go back"
style={{ flexShrink: 0, fontSize: '16px', padding: '4px 8px' }}
></button>
)}
{/* Session name */}
<span className="text-base text-secondary truncate">
{activeSession ? (activeSession.name || activeSession.external_id) : 'New chat'}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }}>
{/* Loaded model pill */}
{modelLabel && (
<span style={{
fontSize: '11px',
color: 'var(--text-muted)',
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: '999px',
padding: '2px 10px',
maxWidth: '200px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{modelLabel}
</span>
)}
{!modelLabel && (
<span style={{
fontSize: '11px',
color: 'var(--text-muted)',
fontStyle: 'italic',
}}>
No model loaded
</span>
)}
{summarising && (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{
width: '10px', height: '10px', borderRadius: '50%',
border: '2px solid var(--accent)',
borderTopColor: 'transparent',
animation: 'spin 0.7s linear infinite',
flexShrink: 0,
}} />
<span style={{ fontSize: '11px', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
Summarising
</span>
</div>
)}
<button className="btn-icon" onClick={onTogglePanel} title="Session info"></button>
</div>
</div>
{/* Message thread */}
<div className="flex-1 scroll-y" style={{ padding: '20px 0' }}>
{!activeSession && (
<div className="flex-col items-center justify-center" style={{
height: '100%',
color: 'var(--text-muted)',
gap: '12px',
}}>
<div style={{ fontSize: '32px', opacity: 0.4 }}></div>
<p className="text-base">Start typing to begin</p>
</div>
)}
{loadingHistory && (
<div className="flex justify-center text-muted" style={{ padding: '40px', fontSize: '13px' }}>
Loading history...
</div>
)}
{!loadingHistory && messages.map(msg => (
<MessageBubble key={msg.id} message={msg} />
))}
<div ref={bottomRef} />
</div>
{/* Input bar */}
<div style={{
borderTop: '1px solid var(--border)',
padding: '12px 16px',
background: 'var(--bg-surface)',
flexShrink: 0,
}}>
<div className="flex items-end" style={{
gap: '10px',
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
padding: '8px 12px',
}}>
<textarea
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Message NexusAI..."
rows={1}
style={{
flex: 1,
background: 'transparent',
border: 'none',
outline: 'none',
color: 'var(--text-primary)',
fontSize: '14px',
lineHeight: '1.6',
resize: 'none',
fontFamily: 'inherit',
maxHeight: '120px',
overflowY: 'auto',
}}
onInput={e => {
e.target.style.height = 'auto';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
/>
{streaming ? (
<button onClick={onCancel} className="btn-reset" style={{
background: 'var(--text-muted)',
borderRadius: 'var(--radius-md)',
width: '32px',
height: '32px',
flexShrink: 0,
color: 'white',
fontSize: '12px',
}}></button>
) : (
<button
onClick={handleSend}
disabled={!input.trim()}
className="btn-primary"
style={{
width: '32px',
height: '32px',
flexShrink: 0,
fontSize: '16px',
border: '1px solid var(--border)',
}}
></button>
)}
</div>
<p className="text-xs text-muted" style={{ textAlign: 'center', marginTop: '8px' }}>
Enter to send · Shift+Enter for new line
</p>
</div>
</div>
);
}