slopfixing / issue 006

This commit is contained in:
legop3
2026-06-12 13:50:28 -04:00
parent 0a6228cb83
commit 01a0b112a1
8 changed files with 510 additions and 386 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -12,7 +12,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<title>Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-DXJpsJXf.js"></script> <script type="module" crossorigin src="/assets/index-Xz-F59g5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BVKa8Zph.css"> <link rel="stylesheet" crossorigin href="/assets/index-BVKa8Zph.css">
</head> </head>
<body> <body>
+1 -1
View File
@@ -1,7 +1,7 @@
1. # FIX ALL OF THE PERFORMANCE PROFILED ISSUES!! 1. # FIX ALL OF THE PERFORMANCE PROFILED ISSUES!!
2. fix up ALL discord admin commands 2. fix up ALL discord admin commands
1. make sure all permissions are correct 1. make sure all permissions are correct
2. fuzzy search 2. fuzzy search all the things
3. dont break on multi word nicknames 3. dont break on multi word nicknames
4. make all rs commands work form both the site chat and discord 4. make all rs commands work form both the site chat and discord
1. make sure all the permissions are correct 1. make sure all the permissions are correct
+287 -248
View File
@@ -1,8 +1,9 @@
// Chat Panel // Chat Panel
// Purpose: Defines the Chat Panel module and the local helpers/components used in this file. // Purpose: Renders the chat transcript, nickname editor, message composer, and optional TTS controls.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps timeline updates isolated from controlled form inputs so incoming chat activity does not
import { useEffect, useMemo, useRef, useState } from 'react'; // force the composer DOM to re-commit while a user is simply watching or driving.
import { useChat } from '../../context/ChatContext.jsx'; import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useChatActions, useChatTimeline } from '../../context/ChatContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js'; import { useSettingsNamespace } from '../../settings/index.js';
import ChatMessageRow from '../ChatMessageRow/index.jsx'; import ChatMessageRow from '../ChatMessageRow/index.jsx';
@@ -16,106 +17,226 @@ const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10);
const GOOGLE_TTS_VALUES = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]; const GOOGLE_TTS_VALUES = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
const DEFAULT_GOOGLE_TTS_VALUE = 1.0; const DEFAULT_GOOGLE_TTS_VALUE = 1.0;
export default function ChatPanel({ const TTS_SETTINGS_DEFAULTS = {
hideInput = false, engine: 'flite',
hideSpectatorNotice = false, voice: 'rms',
fillHeight = false, pitch: 50,
allowSpectatorInput = false, googlePitch: DEFAULT_GOOGLE_TTS_VALUE,
title = 'Chat and TTS', googleSpeed: DEFAULT_GOOGLE_TTS_VALUE,
minimal = false, };
}) {
function resolveTtsSettings(settings) {
return {
engine: settings?.engine || TTS_SETTINGS_DEFAULTS.engine,
voice: settings?.voice || TTS_SETTINGS_DEFAULTS.voice,
pitch: Number.isFinite(settings?.pitch) ? settings.pitch : TTS_SETTINGS_DEFAULTS.pitch,
googlePitch: Number.isFinite(settings?.googlePitch)
? settings.googlePitch
: TTS_SETTINGS_DEFAULTS.googlePitch,
googleSpeed: Number.isFinite(settings?.googleSpeed)
? settings.googleSpeed
: TTS_SETTINGS_DEFAULTS.googleSpeed,
};
}
function useChatComposerSessionState(allowSpectatorInput) {
const role = useSessionSelector((state) => state.session?.role || null); const role = useSessionSelector((state) => state.session?.role || null);
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null); const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
const roster = useSessionSelector((state) => state.session?.roster ?? []); const roster = useSessionSelector((state) => state.session?.roster ?? []);
const rover = useMemo(
() => roster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
[currentRoverId, roster],
);
return {
canChat: role !== 'spectator' || allowSpectatorInput,
ttsSupported: Boolean(rover?.audio?.ttsEnabled),
};
}
function ChatMessageList({ fillHeight = false }) {
const { messages, typing } = useChatTimeline();
const listRef = useRef(null);
const sorted = useMemo(() => messages.slice(-200), [messages]);
const typingRows = useMemo(() => typing || [], [typing]);
const listClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : 'h-48 overflow-y-auto';
useEffect(() => {
if (!listRef.current) return;
// Keep the transcript pinned to the newest activity. This effect intentionally
// lives with the timeline subscriber, because scrolling the message list is the
// only DOM work that should happen when messages or typing indicators change.
listRef.current.scrollTop = listRef.current.scrollHeight;
}, [sorted, typingRows]);
return (
<div className={`overflow-y-auto space-y-0.5 px-0 ${listClass}`} ref={listRef}>
{sorted.length === 0 && typingRows.length === 0 ? (
<p className="text-sm text-slate-500">No messages yet.</p>
) : (
sorted.map((msg) => <ChatMessageRow key={msg.id} message={msg} />)
)}
{typingRows.map((entry) => (
<ChatTypingRow key={`typing-${entry.typingId || entry.id}`} message={entry} />
))}
</div>
);
}
const MemoizedNicknameForm = memo(NicknameForm);
function TtsControls({
ttsSupported,
speak,
onSpeakChange,
engine,
voice,
pitch,
googlePitch,
googleSpeed,
saveTtsSettings,
}) {
if (!ttsSupported) return null;
// Native selects include generous built-in padding and try to preserve the
// width of their longest option. Removing horizontal padding and forcing
// min-w-0 lets the row stay intact while keeping labels readable.
const compactSelectClass = 'field-input min-w-0 max-w-full px-0 text-xs';
return (
<div className="chat-composer-tts">
<label className="flex shrink-0 items-center gap-0.5 whitespace-nowrap text-xs text-slate-300">
<input
type="checkbox"
checked={speak}
onChange={(event) => onSpeakChange(event.target.checked)}
className="accent-cyan-500"
/>
<span>Speak</span>
</label>
<select
value={engine}
onChange={(event) => {
const next = event.target.value;
const nextVoice =
next === 'chromegtts' && !CHROME_TTS_VOICES.includes(voice)
? 'tpf'
: next === 'flite' && !FLITE_VOICES.includes(voice)
? 'rms'
: voice;
saveTtsSettings((current) => ({ ...(current || {}), engine: next, voice: nextVoice }));
}}
className={`${compactSelectClass} w-[5.5rem] shrink`}
>
<option value="flite">flite</option>
<option value="espeak">espeak</option>
<option value="chromegtts">Google speech</option>
</select>
{engine === 'flite' || engine === 'chromegtts' ? (
<>
<select
value={voice}
onChange={(event) => {
const next = event.target.value;
saveTtsSettings((current) => ({ ...(current || {}), voice: next }));
}}
className={`${compactSelectClass} w-[3.25rem] shrink`}
>
{(engine === 'chromegtts' ? CHROME_TTS_VOICES : FLITE_VOICES).map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
{engine === 'chromegtts' && (
<>
<select
value={googlePitch}
onChange={(event) => {
const next = Number(event.target.value);
saveTtsSettings((current) => ({ ...(current || {}), googlePitch: next }));
}}
className={`${compactSelectClass} w-[5rem] shrink`}
>
{GOOGLE_TTS_VALUES.map((value) => (
<option key={`pitch-${value}`} value={value}>
pitch {value.toFixed(2)}
</option>
))}
</select>
<select
value={googleSpeed}
onChange={(event) => {
const next = Number(event.target.value);
saveTtsSettings((current) => ({ ...(current || {}), googleSpeed: next }));
}}
className={`${compactSelectClass} w-[5rem] shrink`}
>
{GOOGLE_TTS_VALUES.map((value) => (
<option key={`speed-${value}`} value={value}>
speed {value.toFixed(2)}
</option>
))}
</select>
</>
)}
</>
) : (
<select
value={pitch}
onChange={(event) => {
const next = Number(event.target.value);
saveTtsSettings((current) => ({ ...(current || {}), pitch: next }));
}}
className={`${compactSelectClass} w-[4.5rem] shrink`}
>
{ESPEAK_PITCHES.map((p) => (
<option key={p} value={p}>
pitch {p}
</option>
))}
</select>
)}
</div>
);
}
function ChatComposer({
allowSpectatorInput = false,
hideSpectatorNotice = false,
}) {
const { const {
messages,
typing,
sendMessage, sendMessage,
registerInputRef, registerInputRef,
onInputFocus, onInputFocus,
onInputBlur, onInputBlur,
blurChat, blurChat,
setTypingActive, setTypingActive,
} = useChat(); } = useChatActions();
const { canChat, ttsSupported } = useChatComposerSessionState(allowSpectatorInput);
const { const {
value: ttsSettings, value: ttsSettings,
save: saveTtsSettings, save: saveTtsSettings,
} = useSettingsNamespace('tts', { } = useSettingsNamespace('tts', TTS_SETTINGS_DEFAULTS);
engine: 'flite', const {
voice: 'rms',
pitch: 50,
googlePitch: DEFAULT_GOOGLE_TTS_VALUE,
googleSpeed: DEFAULT_GOOGLE_TTS_VALUE,
});
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [speak, setSpeak] = useState(false);
const [engine, setEngine] = useState(() => ttsSettings?.engine || 'flite');
const [voice, setVoice] = useState(() => ttsSettings?.voice || 'rms');
const [pitch, setPitch] = useState(() => (Number.isFinite(ttsSettings?.pitch) ? ttsSettings.pitch : 50));
const [googlePitch, setGooglePitch] = useState(() =>
Number.isFinite(ttsSettings?.googlePitch) ? ttsSettings.googlePitch : DEFAULT_GOOGLE_TTS_VALUE,
);
const [googleSpeed, setGoogleSpeed] = useState(() =>
Number.isFinite(ttsSettings?.googleSpeed) ? ttsSettings.googleSpeed : DEFAULT_GOOGLE_TTS_VALUE,
);
const canChat = role !== 'spectator' || allowSpectatorInput;
const listRef = useRef(null);
const rover = useMemo(
() => roster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
[currentRoverId, roster],
);
const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
const effectiveHideInput = minimal || hideInput;
const effectiveTitle = minimal ? '' : title;
const sorted = useMemo(() => messages.slice(-200), [messages]);
const typingRows = useMemo(() => typing || [], [typing]);
useEffect(() => {
if (!listRef.current) return;
listRef.current.scrollTop = listRef.current.scrollHeight;
}, [sorted, typingRows]);
useEffect(() => {
const nextEngine = ttsSettings?.engine || 'flite';
const nextVoice = ttsSettings?.voice || 'rms';
const nextPitch = Number.isFinite(ttsSettings?.pitch) ? ttsSettings.pitch : 50;
const nextGooglePitch = Number.isFinite(ttsSettings?.googlePitch)
? ttsSettings.googlePitch
: DEFAULT_GOOGLE_TTS_VALUE;
const nextGoogleSpeed = Number.isFinite(ttsSettings?.googleSpeed)
? ttsSettings.googleSpeed
: DEFAULT_GOOGLE_TTS_VALUE;
if (engine !== nextEngine) setEngine(nextEngine);
if (voice !== nextVoice) setVoice(nextVoice);
if (pitch !== nextPitch) setPitch(nextPitch);
if (googlePitch !== nextGooglePitch) setGooglePitch(nextGooglePitch);
if (googleSpeed !== nextGoogleSpeed) setGoogleSpeed(nextGoogleSpeed);
}, [
engine, engine,
voice,
pitch,
googlePitch, googlePitch,
googleSpeed, googleSpeed,
pitch, } = resolveTtsSettings(ttsSettings);
ttsSettings?.engine, const [draft, setDraft] = useState('');
ttsSettings?.googlePitch, const [sending, setSending] = useState(false);
ttsSettings?.googleSpeed, const [speak, setSpeak] = useState(true);
ttsSettings?.pitch, const effectiveSpeak = ttsSupported && speak;
ttsSettings?.voice,
voice,
]);
useEffect(() => {
if (ttsSupported) {
setSpeak(true);
} else {
setSpeak(false);
}
}, [ttsSupported]);
const ttsPayload = useMemo(() => { const ttsPayload = useMemo(() => {
if (!ttsSupported || !speak) return null; if (!effectiveSpeak) return null;
// The payload is derived during render from the current settings instead of
// mirrored into local state. That removes an effect-driven state sync path and
// keeps the composer render count tied to real user/settings changes.
if (engine === 'espeak') { if (engine === 'espeak') {
return { speak: true, engine, pitch }; return { speak: true, engine, pitch };
} }
@@ -123,15 +244,19 @@ export default function ChatPanel({
return { speak: true, engine, voice, pitch: googlePitch, speed: googleSpeed }; return { speak: true, engine, voice, pitch: googlePitch, speed: googleSpeed };
} }
return { speak: true, engine, voice }; return { speak: true, engine, voice };
}, [engine, googlePitch, googleSpeed, pitch, speak, ttsSupported, voice]); }, [effectiveSpeak, engine, googlePitch, googleSpeed, pitch, voice]);
async function handleSend(event) { async function handleSend(event) {
event.preventDefault(); event.preventDefault();
if (!canChat || effectiveHideInput) return; if (!canChat) return;
// Allow users to type "\n" to represent a newline in messages
// Allow users to type "\n" to represent a newline in messages. This keeps the
// single-line composer compatible with users who still want line breaks in the
// delivered chat payload.
const normalizedDraft = draft.replace(/\\n/g, '\n'); const normalizedDraft = draft.replace(/\\n/g, '\n');
const clean = normalizedDraft.trim(); const clean = normalizedDraft.trim();
if (!clean) return; if (!clean) return;
setSending(true); setSending(true);
try { try {
await sendMessage(clean, ttsPayload); await sendMessage(clean, ttsPayload);
@@ -145,177 +270,91 @@ export default function ChatPanel({
} }
} }
const listClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : 'h-48 overflow-y-auto';
// The composer class owns the responsive row behavior in CSS. A container // The composer class owns the responsive row behavior in CSS. A container
// query is used instead of JavaScript so the layout responds to the actual // query is used instead of JavaScript so the layout responds to the actual
// panel width, which matters because this component appears in sidebars and // panel width, which matters because this component appears in sidebars and
// mobile layouts that do not map cleanly to viewport breakpoints. // mobile layouts that do not map cleanly to viewport breakpoints.
const composerClass = 'chat-composer'; return (
// Native selects include generous built-in padding and try to preserve the <form className="chat-composer" onSubmit={handleSend}>
// width of their longest option. Removing horizontal padding and forcing <div className="chat-composer-nickname">
// min-w-0 lets the row stay intact while keeping labels readable. <MemoizedNicknameForm compact />
const compactSelectClass = 'field-input min-w-0 max-w-full px-0 text-xs'; </div>
<input
className="field-input chat-composer-input"
value={draft}
onChange={(event) => {
const next = event.target.value;
setDraft(next);
setTypingActive(Boolean(next.trim()));
}}
onFocus={(event) => {
onInputFocus(event);
setTypingActive(Boolean(draft.trim()));
}}
onBlur={(event) => {
onInputBlur(event);
setTypingActive(false);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !draft.trim()) {
event.preventDefault();
blurChat();
setTypingActive(false);
}
}}
ref={(el) => registerInputRef(el, { target: 'panel' })}
placeholder={canChat ? 'Type a message…' : hideSpectatorNotice ? '' : 'Spectators cannot chat'}
disabled={!canChat}
/>
<button
type="submit"
disabled={!canChat || sending}
className="button-dark chat-composer-send disabled:opacity-50"
>
{sending ? '...' : 'Send'}
</button>
<TtsControls
ttsSupported={ttsSupported}
speak={effectiveSpeak}
onSpeakChange={setSpeak}
engine={engine}
voice={voice}
pitch={pitch}
googlePitch={googlePitch}
googleSpeed={googleSpeed}
saveTtsSettings={saveTtsSettings}
/>
</form>
);
}
const MemoizedChatMessageList = memo(ChatMessageList);
const MemoizedChatComposer = memo(ChatComposer);
export default function ChatPanel({
hideInput = false,
hideSpectatorNotice = false,
fillHeight = false,
allowSpectatorInput = false,
title = 'Chat and speech',
minimal = false,
}) {
const effectiveHideInput = minimal || hideInput;
const effectiveTitle = minimal ? '' : title;
return ( return (
<CardFrame <CardFrame
title={effectiveTitle} title={effectiveTitle}
hideHeader={minimal || !effectiveTitle} hideHeader={minimal || !effectiveTitle}
fillHeight={fillHeight} fillHeight={fillHeight}
bodyClassName="space-y-0.5 text-base" bodyClassName="space-y-0.5 text-base"
> >
<div className={`overflow-y-auto space-y-0.5 px-0 ${listClass}`} ref={listRef}> <MemoizedChatMessageList fillHeight={fillHeight} />
{sorted.length === 0 && typingRows.length === 0 ? (
<p className="text-sm text-slate-500">No messages yet.</p>
) : (
sorted.map((msg) => <ChatMessageRow key={msg.id} message={msg} />)
)}
{typingRows.map((entry) => (
<ChatTypingRow key={`typing-${entry.typingId || entry.id}`} message={entry} />
))}
</div>
{!effectiveHideInput && ( {!effectiveHideInput && (
<form className={composerClass} onSubmit={handleSend}> <MemoizedChatComposer
<div className="chat-composer-nickname"> allowSpectatorInput={allowSpectatorInput}
<NicknameForm compact /> hideSpectatorNotice={hideSpectatorNotice}
</div> />
<input
className="field-input chat-composer-input"
value={draft}
onChange={(e) => {
const next = e.target.value;
setDraft(next);
setTypingActive(Boolean(next.trim()));
}}
onFocus={(event) => {
onInputFocus(event);
setTypingActive(Boolean(draft.trim()));
}}
onBlur={(event) => {
onInputBlur(event);
setTypingActive(false);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !draft.trim()) {
event.preventDefault();
blurChat();
setTypingActive(false);
}
}}
ref={(el) => registerInputRef(el, { target: 'panel' })}
placeholder={canChat ? 'Type a message…' : hideSpectatorNotice ? '' : 'Spectators cannot chat'}
disabled={!canChat}
/>
<button
type="submit"
disabled={!canChat || sending}
className="button-dark chat-composer-send disabled:opacity-50"
>
{sending ? '...' : 'Send'}
</button>
{ttsSupported && (
<div className="chat-composer-tts">
<label className="flex shrink-0 items-center gap-0.5 whitespace-nowrap text-xs text-slate-300">
<input
type="checkbox"
checked={speak}
onChange={(e) => setSpeak(e.target.checked)}
className="accent-cyan-500"
/>
<span>Speak</span>
</label>
<select
value={engine}
onChange={(e) => {
const next = e.target.value;
const nextVoice =
next === 'chromegtts' && !CHROME_TTS_VOICES.includes(voice)
? 'tpf'
: next === 'flite' && !FLITE_VOICES.includes(voice)
? 'rms'
: voice;
setEngine(next);
if (nextVoice !== voice) setVoice(nextVoice);
saveTtsSettings((current) => ({ ...(current || {}), engine: next, voice: nextVoice }));
}}
className={`${compactSelectClass} w-[5.5rem] shrink`}
>
<option value="flite">flite</option>
<option value="espeak">espeak</option>
<option value="chromegtts">Google TTS</option>
</select>
{engine === 'flite' || engine === 'chromegtts' ? (
<>
<select
value={voice}
onChange={(e) => {
const next = e.target.value;
setVoice(next);
saveTtsSettings((current) => ({ ...(current || {}), voice: next }));
}}
className={`${compactSelectClass} w-[3.25rem] shrink`}
>
{(engine === 'chromegtts' ? CHROME_TTS_VOICES : FLITE_VOICES).map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
{engine === 'chromegtts' && (
<>
<select
value={googlePitch}
onChange={(e) => {
const next = Number(e.target.value);
setGooglePitch(next);
saveTtsSettings((current) => ({ ...(current || {}), googlePitch: next }));
}}
className={`${compactSelectClass} w-[5rem] shrink`}
>
{GOOGLE_TTS_VALUES.map((value) => (
<option key={`pitch-${value}`} value={value}>
pitch {value.toFixed(2)}
</option>
))}
</select>
<select
value={googleSpeed}
onChange={(e) => {
const next = Number(e.target.value);
setGoogleSpeed(next);
saveTtsSettings((current) => ({ ...(current || {}), googleSpeed: next }));
}}
className={`${compactSelectClass} w-[5rem] shrink`}
>
{GOOGLE_TTS_VALUES.map((value) => (
<option key={`speed-${value}`} value={value}>
speed {value.toFixed(2)}
</option>
))}
</select>
</>
)}
</>
) : (
<select
value={pitch}
onChange={(e) => {
const next = Number(e.target.value);
setPitch(next);
saveTtsSettings((current) => ({ ...(current || {}), pitch: next }));
}}
className={`${compactSelectClass} w-[4.5rem] shrink`}
>
{ESPEAK_PITCHES.map((p) => (
<option key={p} value={p}>
pitch {p}
</option>
))}
</select>
)}
</div>
)}
</form>
)} )}
</CardFrame> </CardFrame>
); );
@@ -2,7 +2,7 @@
// Purpose: Defines the Hud Chat Input module and the local helpers/components used in this file. // Purpose: Defines the Hud Chat Input module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { memo, useMemo, useState } from 'react'; import { memo, useMemo, useState } from 'react';
import { useChat } from '../../../context/ChatContext.jsx'; import { useChatActions } from '../../../context/ChatContext.jsx';
import { useSessionSelector } from '../../../context/SessionContext.jsx'; import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../../settings/index.js'; import { useSettingsNamespace } from '../../../settings/index.js';
@@ -26,7 +26,7 @@ function HudChatInput({ compact = false }) {
const role = useSessionSelector((state) => state.session?.role || null); const role = useSessionSelector((state) => state.session?.role || null);
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null); const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
const roverRoster = useSessionSelector((state) => state.session?.roster || []); const roverRoster = useSessionSelector((state) => state.session?.roster || []);
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChat(); const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChatActions();
const { value: ttsSettings } = useSettingsNamespace('tts', { const { value: ttsSettings } = useSettingsNamespace('tts', {
engine: 'flite', engine: 'flite',
voice: 'rms', voice: 'rms',
+90 -6
View File
@@ -9,9 +9,12 @@ import messageSound from '../assets/message.mp3';
import { useSettingsNamespace } from '../settings/index.js'; import { useSettingsNamespace } from '../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js'; import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
const ChatContext = createContext({ const CHAT_TIMELINE_DEFAULT = {
messages: [], messages: [],
typing: [], typing: [],
};
const CHAT_ACTIONS_DEFAULT = {
sendMessage: async () => {}, sendMessage: async () => {},
focusChat: () => {}, focusChat: () => {},
blurChat: () => {}, blurChat: () => {},
@@ -19,7 +22,31 @@ const ChatContext = createContext({
onInputFocus: () => {}, onInputFocus: () => {},
onInputBlur: () => {}, onInputBlur: () => {},
setTypingActive: () => {}, setTypingActive: () => {},
};
const CHAT_FOCUS_DEFAULT = {
isChatFocused: false, isChatFocused: false,
selfSocketId: null,
};
// 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.
const ChatTimelineContext = createContext(CHAT_TIMELINE_DEFAULT);
// Actions are intentionally separate from timeline state. Consumers such as the
// HUD composer only need stable command functions, so subscribing them to the
// message array would recreate the performance issue this provider is avoiding.
const ChatActionsContext = createContext(CHAT_ACTIONS_DEFAULT);
// Focus state is used by keyboard input capture. It changes for input focus and
// blur events, but it should not be tied to incoming chat traffic either.
const ChatFocusContext = createContext(CHAT_FOCUS_DEFAULT);
const ChatContext = createContext({
...CHAT_TIMELINE_DEFAULT,
...CHAT_ACTIONS_DEFAULT,
...CHAT_FOCUS_DEFAULT,
}); });
export function ChatProvider({ children }) { export function ChatProvider({ children }) {
@@ -91,7 +118,7 @@ export function ChatProvider({ children }) {
return () => { return () => {
socket.off('chat:message', handleMessage); socket.off('chat:message', handleMessage);
}; };
}, [playSound, session?.socketId, socket]); }, [playSound, pushAlert, session?.socketId, socket]);
useEffect(() => { useEffect(() => {
function handleTyping(payload = {}) { function handleTyping(payload = {}) {
@@ -221,9 +248,18 @@ export function ChatProvider({ children }) {
const onInputFocus = useCallback(() => setIsChatFocused(true), []); const onInputFocus = useCallback(() => setIsChatFocused(true), []);
const onInputBlur = useCallback(() => setIsChatFocused(false), []); const onInputBlur = useCallback(() => setIsChatFocused(false), []);
const value = useMemo( // Each published value is memoized independently so React only wakes the
// consumers attached to the slice that actually changed.
const timelineValue = useMemo(
() => ({ () => ({
messages, messages,
typing,
}),
[messages, typing],
);
const actionsValue = useMemo(
() => ({
sendMessage, sendMessage,
focusChat, focusChat,
blurChat, blurChat,
@@ -231,14 +267,38 @@ export function ChatProvider({ children }) {
onInputFocus, onInputFocus,
onInputBlur, onInputBlur,
setTypingActive, setTypingActive,
typing, }),
[blurChat, focusChat, onInputBlur, onInputFocus, registerInputRef, sendMessage, setTypingActive],
);
const focusValue = useMemo(
() => ({
isChatFocused, isChatFocused,
selfSocketId: session?.socketId || null, selfSocketId: session?.socketId || null,
}), }),
[blurChat, focusChat, isChatFocused, messages, onInputBlur, onInputFocus, registerInputRef, sendMessage, session?.socketId, setTypingActive, typing], [isChatFocused, session?.socketId],
); );
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>; const value = useMemo(
() => ({
...timelineValue,
...actionsValue,
...focusValue,
}),
[actionsValue, focusValue, timelineValue],
);
return (
// Nesting the focused providers keeps the public provider API unchanged for
// callers while giving individual consumers smaller subscriptions.
<ChatTimelineContext.Provider value={timelineValue}>
<ChatActionsContext.Provider value={actionsValue}>
<ChatFocusContext.Provider value={focusValue}>
<ChatContext.Provider value={value}>{children}</ChatContext.Provider>
</ChatFocusContext.Provider>
</ChatActionsContext.Provider>
</ChatTimelineContext.Provider>
);
} }
export function useChat() { export function useChat() {
@@ -248,3 +308,27 @@ export function useChat() {
} }
return ctx; return ctx;
} }
export function useChatTimeline() {
const ctx = useContext(ChatTimelineContext);
if (!ctx) {
throw new Error('useChatTimeline must be used inside ChatProvider');
}
return ctx;
}
export function useChatActions() {
const ctx = useContext(ChatActionsContext);
if (!ctx) {
throw new Error('useChatActions must be used inside ChatProvider');
}
return ctx;
}
export function useChatFocus() {
const ctx = useContext(ChatFocusContext);
if (!ctx) {
throw new Error('useChatFocus must be used inside ChatProvider');
}
return ctx;
}
@@ -2,7 +2,7 @@
// Purpose: Captures and translates keyboard events into normalized control intents. Scope: Owns keydown/keyup listeners and dispatch coordination for drive controls. // Purpose: Captures and translates keyboard events into normalized control intents. Scope: Owns keydown/keyup listeners and dispatch coordination for drive controls.
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import { useControlActions, useControlSelector } from '../ControlContext.jsx'; import { useControlActions, useControlSelector } from '../ControlContext.jsx';
import { useChat } from '../../context/ChatContext.jsx'; import { useChatActions, useChatFocus } from '../../context/ChatContext.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { normalizeKeymapEntries, tokensForEvent } from '../keymapUtils.js'; import { normalizeKeymapEntries, tokensForEvent } from '../keymapUtils.js';
import { isKeyboardCaptureLocked } from './keyboardCaptureLock.js'; import { isKeyboardCaptureLocked } from './keyboardCaptureLock.js';
@@ -105,7 +105,8 @@ export default function KeyboardInputManager() {
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null); const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
const dockAssist = useManualDockAssist(); const dockAssist = useManualDockAssist();
const { homeAssistantSetState } = useSessionActions(); const { homeAssistantSetState } = useSessionActions();
const { focusChat, isChatFocused } = useChat(); const { focusChat } = useChatActions();
const { isChatFocused } = useChatFocus();
const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS); const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
const { save: saveVideoSettings } = useSettingsNamespace('video', VIDEO_SETTINGS_DEFAULTS); const { save: saveVideoSettings } = useSettingsNamespace('video', VIDEO_SETTINGS_DEFAULTS);
const keymap = useMemo(() => normalizeKeymapEntries(rawKeymap), [rawKeymap]); const keymap = useMemo(() => normalizeKeymapEntries(rawKeymap), [rawKeymap]);