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:
parent
8a2d0e1a72
commit
0b64c6ccff
23 changed files with 214 additions and 29 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue