MosswartOverlord/frontend/src/components/windows/ChatWindow.tsx
Erik 0b64c6ccff feat(v2): chat command history + smart auto-scroll
Command history:
- Up/Down arrow keys browse sent command history (like bash/console)
- 50 commands stored per character in localStorage
- Persists across page reloads and browser sessions
- Current input preserved when browsing (restored on Down past end)
- Duplicates kept (matches user preference)

Smart auto-scroll:
- New messages only auto-scroll if user is already at the bottom
- If user has scrolled up to read history, it stays put
- Sending a message snaps back to bottom
- 30px threshold for "at bottom" detection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:51:45 +02:00

127 lines
4.2 KiB
TypeScript

import React, { useEffect, useRef, useState, useCallback } from 'react';
import { DraggableWindow } from './DraggableWindow';
interface ChatMsg {
text: string;
color?: number;
timestamp: string;
}
const CHAT_COLORS: Record<number, string> = {
0:'#00FF00', 2:'#FFFFFF', 3:'#FF0000', 4:'#FFFFFF', 5:'#33CCFF', 6:'#CCFF99',
7:'#00FFFF', 14:'#FFD700', 15:'#FF69B4', 17:'#AAAAFF', 18:'#88FF88',
21:'#FF8888', 22:'#FFAA66',
};
const MAX_HISTORY = 50;
const HISTORY_KEY = (name: string) => `mo-chat-history-${name}`;
function loadHistory(charName: string): string[] {
try {
const raw = localStorage.getItem(HISTORY_KEY(charName));
return raw ? JSON.parse(raw) : [];
} catch { return []; }
}
function saveHistory(charName: string, history: string[]) {
try {
localStorage.setItem(HISTORY_KEY(charName), JSON.stringify(history.slice(-MAX_HISTORY)));
} catch { /* quota exceeded — ignore */ }
}
interface Props {
id: string;
charName: string;
zIndex: number;
messages: ChatMsg[];
socket: WebSocket | null;
}
export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages, socket }) => {
const msgsRef = useRef<HTMLDivElement>(null);
const [input, setInput] = useState('');
const historyRef = useRef<string[]>(loadHistory(charName));
const historyIndexRef = useRef(-1); // -1 = not browsing history
const savedInputRef = useRef(''); // preserves current input when browsing history
const userScrolledRef = useRef(false);
// Auto-scroll only if user is already at the bottom
useEffect(() => {
const el = msgsRef.current;
if (!el) return;
if (!userScrolledRef.current) {
el.scrollTop = el.scrollHeight;
}
}, [messages.length]);
// Track whether user has scrolled up from bottom
const handleScroll = useCallback(() => {
const el = msgsRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 30;
userScrolledRef.current = !atBottom;
}, []);
const handleSend = useCallback((e: React.FormEvent) => {
e.preventDefault();
const text = input.trim();
if (!text || !socket || socket.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify({ player_name: charName, command: text }));
// Add to history
historyRef.current.push(text);
if (historyRef.current.length > MAX_HISTORY) historyRef.current.shift();
saveHistory(charName, historyRef.current);
historyIndexRef.current = -1;
savedInputRef.current = '';
setInput('');
// Snap back to bottom on send
userScrolledRef.current = false;
}, [input, socket, charName]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
const history = historyRef.current;
if (history.length === 0) return;
if (e.key === 'ArrowUp') {
e.preventDefault();
if (historyIndexRef.current === -1) {
// Starting to browse — save current input
savedInputRef.current = input;
historyIndexRef.current = history.length - 1;
} else if (historyIndexRef.current > 0) {
historyIndexRef.current--;
}
setInput(history[historyIndexRef.current]);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIndexRef.current === -1) return; // not browsing
if (historyIndexRef.current < history.length - 1) {
historyIndexRef.current++;
setInput(history[historyIndexRef.current]);
} else {
// Past the end — restore saved input
historyIndexRef.current = -1;
setInput(savedInputRef.current);
}
}
}, [input]);
return (
<DraggableWindow id={id} title={`Chat: ${charName}`} zIndex={zIndex} width={600} height={300}>
<div className="ml-chat-messages" ref={msgsRef} onScroll={handleScroll}>
{messages.map((m, i) => (
<div key={i} className="ml-chat-line" style={{ color: CHAT_COLORS[m.color ?? 2] ?? '#ddd' }}>
{m.text}
</div>
))}
</div>
<form className="ml-chat-form" onSubmit={handleSend}>
<input className="ml-chat-input" value={input} onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown} placeholder="Enter chat..." />
</form>
</DraggableWindow>
);
};