206 lines
6.2 KiB
JavaScript
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>
|
|
);
|
|
} |