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>
This commit is contained in:
Erik 2026-04-14 12:51:45 +02:00
parent 8a2d0e1a72
commit 0b64c6ccff
23 changed files with 214 additions and 29 deletions

View file

@ -1,6 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { DraggableWindow } from './DraggableWindow';
import { wsUrl } from '../../api/client';
interface ChatMsg {
text: string;
@ -14,6 +13,22 @@ const CHAT_COLORS: Record<number, string> = {
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;
@ -25,23 +40,78 @@ interface Props {
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(() => {
if (msgsRef.current) msgsRef.current.scrollTop = msgsRef.current.scrollHeight;
const el = msgsRef.current;
if (!el) return;
if (!userScrolledRef.current) {
el.scrollTop = el.scrollHeight;
}
}, [messages.length]);
const handleSend = (e: React.FormEvent) => {
// 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;
// v1 envelope: { player_name, command }
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}>
<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}
@ -49,7 +119,8 @@ export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages, so
))}
</div>
<form className="ml-chat-form" onSubmit={handleSend}>
<input className="ml-chat-input" value={input} onChange={e => setInput(e.target.value)} placeholder="Enter chat..." />
<input className="ml-chat-input" value={input} onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown} placeholder="Enter chat..." />
</form>
</DraggableWindow>
);