chat history and other stuffs, light commands replay fixes

This commit is contained in:
legop3
2026-07-18 13:10:08 -04:00
parent 8a4162683f
commit cacd125fcb
16 changed files with 447 additions and 162 deletions
+15
View File
@@ -6,6 +6,7 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useChatActions, useChatTimeline } from '../../context/ChatContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import useChatMessageHistoryNavigation from '../../hooks/useChatMessageHistoryNavigation.js';
import ChatMessageRow from '../ChatMessageRow/index.jsx';
import CardFrame from '../CardFrame/index.jsx';
import NicknameForm from '../NicknameForm/index.jsx';
@@ -260,6 +261,7 @@ function ChatComposer({
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [speak, setSpeak] = useState(true);
const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation();
const effectiveSpeak = ttsSupported && speak;
const ttsPayload = useMemo(() => {
if (!effectiveSpeak) return null;
@@ -291,6 +293,7 @@ function ChatComposer({
try {
await sendMessage(clean, ttsPayload);
setDraft('');
resetHistoryNavigation();
blurChat();
setTypingActive(false);
} catch (err) {
@@ -314,6 +317,9 @@ function ChatComposer({
value={draft}
onChange={(event) => {
const next = event.target.value;
// A direct edit starts a fresh history traversal. This prevents an
// old ArrowDown position from overwriting text the user just typed.
resetHistoryNavigation();
setDraft(next);
setTypingActive(Boolean(next.trim()));
}}
@@ -326,6 +332,15 @@ function ChatComposer({
setTypingActive(false);
}}
onKeyDown={(event) => {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const recalledDraft = navigateHistory(event.key === 'ArrowUp' ? 'previous' : 'next', draft);
if (recalledDraft !== null) {
event.preventDefault();
setDraft(recalledDraft);
setTypingActive(Boolean(recalledDraft.trim()));
}
return;
}
if (event.key === 'Enter' && !draft.trim()) {
event.preventDefault();
blurChat();
@@ -5,6 +5,7 @@ import { memo, useMemo, useState } from 'react';
import { useChatActions } from '../../../context/ChatContext.jsx';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../../settings/index.js';
import useChatMessageHistoryNavigation from '../../../hooks/useChatMessageHistoryNavigation.js';
function detectSafari() {
if (typeof navigator === 'undefined') return false;
@@ -42,6 +43,7 @@ function HudChatInput({ compact = false }) {
});
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation();
const canChat = role !== 'spectator';
const hideHudChat = role === 'spectator';
const chatTargetId = useMemo(() => {
@@ -118,6 +120,7 @@ function HudChatInput({ compact = false }) {
try {
await sendMessage(clean, ttsPayload);
setDraft('');
resetHistoryNavigation();
blurChat();
setTypingActive(false);
} catch (err) {
@@ -136,6 +139,9 @@ function HudChatInput({ compact = false }) {
value={draft}
onChange={(event) => {
const next = event.target.value;
// Keep HUD navigation independent from the panel's cursor even
// though both inputs read the same persisted message collection.
resetHistoryNavigation();
setDraft(next);
setTypingActive(Boolean(next.trim()));
}}
@@ -148,6 +154,15 @@ function HudChatInput({ compact = false }) {
setTypingActive(false);
}}
onKeyDown={(event) => {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const recalledDraft = navigateHistory(event.key === 'ArrowUp' ? 'previous' : 'next', draft);
if (recalledDraft !== null) {
event.preventDefault();
setDraft(recalledDraft);
setTypingActive(Boolean(recalledDraft.trim()));
}
return;
}
if (event.key === 'Enter' && !draft.trim()) {
event.preventDefault();
blurChat();
+54 -3
View File
@@ -30,6 +30,12 @@ const CHAT_FOCUS_DEFAULT = {
selfSocketId: null,
};
const CHAT_HISTORY_DEFAULT = {
messageHistory: [],
};
const CHAT_HISTORY_LIMIT = 10;
const CHAT_HISTORY_ENTRY_LIMIT = 200;
// Chat messages and typing indicators are the highest-churn chat data. Keeping
// them in their own context lets transcript components update without forcing
// controlled composer inputs to re-render and re-commit unchanged attributes.
@@ -44,6 +50,12 @@ const ChatActionsContext = createContext(CHAT_ACTIONS_DEFAULT);
// blur events, but it should not be tied to incoming chat traffic either.
const ChatFocusContext = createContext(CHAT_FOCUS_DEFAULT);
// Sent-message history has its own subscription because it changes only when
// this browser successfully posts a message. Keeping it separate prevents the
// transcript and focus consumers from re-rendering when the persisted history
// changes, while still giving every mounted composer one shared history source.
const ChatHistoryContext = createContext(CHAT_HISTORY_DEFAULT);
const ChatContext = createContext({
...CHAT_TIMELINE_DEFAULT,
...CHAT_ACTIONS_DEFAULT,
@@ -56,6 +68,7 @@ export function ChatProvider({ children }) {
const { pushAlert } = useSessionActions();
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const { value: profileSettings } = useSettingsNamespace('profile', { nickname: '', profileImageUrl: '' });
const { value: chatSettings, save: saveChatSettings } = useSettingsNamespace('chat', CHAT_HISTORY_DEFAULT);
const [messages, setMessages] = useState([]);
const [typing, setTyping] = useState([]);
const [isChatFocused, setIsChatFocused] = useState(false);
@@ -229,11 +242,29 @@ export function ChatProvider({ children }) {
hasTts: Boolean(tts),
length: typeof text === 'string' ? text.trim().length : 0,
});
/*
Record only messages accepted by the server so ArrowUp never
recalls a draft that failed to send. The settings subsystem uses
one browser cookie for every namespace, so both the entry length
and history count are deliberately bounded to leave room for the
user's other persisted preferences.
*/
const historyEntry = typeof text === 'string' ? text.slice(0, CHAT_HISTORY_ENTRY_LIMIT) : '';
if (historyEntry) {
saveChatSettings((current) => {
const currentHistory = Array.isArray(current?.messageHistory) ? current.messageHistory : [];
return {
...(current || {}),
messageHistory: [...currentHistory.slice(-(CHAT_HISTORY_LIMIT - 1)), historyEntry],
};
});
}
resolve(resp);
}
});
}),
[profileImage, socket],
[profileImage, saveChatSettings, socket],
);
const registerInputRef = useCallback((el, options = {}) => {
@@ -301,13 +332,23 @@ export function ChatProvider({ children }) {
[isChatFocused, session?.socketId],
);
const historyValue = useMemo(
() => ({
// Treat malformed or hand-edited cookie data as an empty history. This
// keeps keyboard navigation safe without mutating unrelated settings.
messageHistory: Array.isArray(chatSettings?.messageHistory) ? chatSettings.messageHistory : [],
}),
[chatSettings],
);
const value = useMemo(
() => ({
...timelineValue,
...actionsValue,
...focusValue,
...historyValue,
}),
[actionsValue, focusValue, timelineValue],
[actionsValue, focusValue, historyValue, timelineValue],
);
return (
@@ -316,7 +357,9 @@ export function ChatProvider({ children }) {
<ChatTimelineContext.Provider value={timelineValue}>
<ChatActionsContext.Provider value={actionsValue}>
<ChatFocusContext.Provider value={focusValue}>
<ChatContext.Provider value={value}>{children}</ChatContext.Provider>
<ChatHistoryContext.Provider value={historyValue}>
<ChatContext.Provider value={value}>{children}</ChatContext.Provider>
</ChatHistoryContext.Provider>
</ChatFocusContext.Provider>
</ChatActionsContext.Provider>
</ChatTimelineContext.Provider>
@@ -354,3 +397,11 @@ export function useChatFocus() {
}
return ctx;
}
export function useChatHistory() {
const ctx = useContext(ChatHistoryContext);
if (!ctx) {
throw new Error('useChatHistory must be used inside ChatProvider');
}
return ctx;
}
@@ -0,0 +1,52 @@
// Chat Message History Navigation
// Purpose: Gives each chat input Bash-style traversal over the shared persisted send history.
// Scope: Owns only draft/navigation state; it does not register global keys or interact with rover controls.
import { useCallback, useRef } from 'react';
import { useChatHistory } from '../context/ChatContext.jsx';
export default function useChatMessageHistoryNavigation() {
const { messageHistory } = useChatHistory();
const historyIndexRef = useRef(null);
const preservedDraftRef = useRef('');
const resetHistoryNavigation = useCallback(() => {
// Manual edits and successful sends begin a new navigation session. The
// current input value remains owned by the composer and is not changed here.
historyIndexRef.current = null;
preservedDraftRef.current = '';
}, []);
const navigateHistory = useCallback(
(direction, currentDraft) => {
if (!messageHistory.length) return null;
if (direction === 'previous') {
if (historyIndexRef.current === null) {
// Save the in-progress draft exactly once so ArrowDown can restore it
// after the user reaches the newest edge of history, like a shell.
preservedDraftRef.current = currentDraft;
historyIndexRef.current = messageHistory.length - 1;
} else {
historyIndexRef.current = Math.max(0, historyIndexRef.current - 1);
}
return messageHistory[historyIndexRef.current];
}
if (direction === 'next' && historyIndexRef.current !== null) {
if (historyIndexRef.current < messageHistory.length - 1) {
historyIndexRef.current += 1;
return messageHistory[historyIndexRef.current];
}
const preservedDraft = preservedDraftRef.current;
resetHistoryNavigation();
return preservedDraft;
}
return null;
},
[messageHistory, resetHistoryNavigation],
);
return { navigateHistory, resetHistoryNavigation };
}