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-title" content="Roomba Rover" />
<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">
</head>
<body>
+1 -1
View File
@@ -1,7 +1,7 @@
1. # FIX ALL OF THE PERFORMANCE PROFILED ISSUES!!
2. fix up ALL discord admin commands
1. make sure all permissions are correct
2. fuzzy search
2. fuzzy search all the things
3. dont break on multi word nicknames
4. make all rs commands work form both the site chat and discord
1. make sure all the permissions are correct
+259 -220
View File
@@ -1,8 +1,9 @@
// Chat Panel
// Purpose: Defines the Chat Panel module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useChat } from '../../context/ChatContext.jsx';
// Purpose: Renders the chat transcript, nickname editor, message composer, and optional TTS controls.
// Scope: Keeps timeline updates isolated from controlled form inputs so incoming chat activity does not
// force the composer DOM to re-commit while a user is simply watching or driving.
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 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 DEFAULT_GOOGLE_TTS_VALUE = 1.0;
export default function ChatPanel({
hideInput = false,
hideSpectatorNotice = false,
fillHeight = false,
allowSpectatorInput = false,
title = 'Chat and TTS',
minimal = false,
}) {
const TTS_SETTINGS_DEFAULTS = {
engine: 'flite',
voice: 'rms',
pitch: 50,
googlePitch: DEFAULT_GOOGLE_TTS_VALUE,
googleSpeed: DEFAULT_GOOGLE_TTS_VALUE,
};
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 currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
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 {
messages,
typing,
sendMessage,
registerInputRef,
onInputFocus,
onInputBlur,
blurChat,
setTypingActive,
} = useChat();
} = useChatActions();
const { canChat, ttsSupported } = useChatComposerSessionState(allowSpectatorInput);
const {
value: ttsSettings,
save: saveTtsSettings,
} = useSettingsNamespace('tts', {
engine: 'flite',
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);
}, [
} = useSettingsNamespace('tts', TTS_SETTINGS_DEFAULTS);
const {
engine,
voice,
pitch,
googlePitch,
googleSpeed,
pitch,
ttsSettings?.engine,
ttsSettings?.googlePitch,
ttsSettings?.googleSpeed,
ttsSettings?.pitch,
ttsSettings?.voice,
voice,
]);
useEffect(() => {
if (ttsSupported) {
setSpeak(true);
} else {
setSpeak(false);
}
}, [ttsSupported]);
} = resolveTtsSettings(ttsSettings);
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [speak, setSpeak] = useState(true);
const effectiveSpeak = ttsSupported && speak;
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') {
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 };
}, [engine, googlePitch, googleSpeed, pitch, speak, ttsSupported, voice]);
}, [effectiveSpeak, engine, googlePitch, googleSpeed, pitch, voice]);
async function handleSend(event) {
event.preventDefault();
if (!canChat || effectiveHideInput) return;
// Allow users to type "\n" to represent a newline in messages
if (!canChat) return;
// 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 clean = normalizedDraft.trim();
if (!clean) return;
setSending(true);
try {
await sendMessage(clean, ttsPayload);
@@ -145,45 +270,20 @@ 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
// query is used instead of JavaScript so the layout responds to the actual
// panel width, which matters because this component appears in sidebars and
// mobile layouts that do not map cleanly to viewport breakpoints.
const composerClass = 'chat-composer';
// 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 (
<CardFrame
title={effectiveTitle}
hideHeader={minimal || !effectiveTitle}
fillHeight={fillHeight}
bodyClassName="space-y-0.5 text-base"
>
<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>
{!effectiveHideInput && (
<form className={composerClass} onSubmit={handleSend}>
<form className="chat-composer" onSubmit={handleSend}>
<div className="chat-composer-nickname">
<NicknameForm compact />
<MemoizedNicknameForm compact />
</div>
<input
className="field-input chat-composer-input"
value={draft}
onChange={(e) => {
const next = e.target.value;
onChange={(event) => {
const next = event.target.value;
setDraft(next);
setTypingActive(Boolean(next.trim()));
}}
@@ -213,109 +313,48 @@ export default function ChatPanel({
>
{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"
<TtsControls
ttsSupported={ttsSupported}
speak={effectiveSpeak}
onSpeakChange={setSpeak}
engine={engine}
voice={voice}
pitch={pitch}
googlePitch={googlePitch}
googleSpeed={googleSpeed}
saveTtsSettings={saveTtsSettings}
/>
<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>
);
}
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 (
<CardFrame
title={effectiveTitle}
hideHeader={minimal || !effectiveTitle}
fillHeight={fillHeight}
bodyClassName="space-y-0.5 text-base"
>
<MemoizedChatMessageList fillHeight={fillHeight} />
{!effectiveHideInput && (
<MemoizedChatComposer
allowSpectatorInput={allowSpectatorInput}
hideSpectatorNotice={hideSpectatorNotice}
/>
)}
</CardFrame>
);
@@ -2,7 +2,7 @@
// 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.
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 { useSettingsNamespace } from '../../../settings/index.js';
@@ -26,7 +26,7 @@ function HudChatInput({ compact = false }) {
const role = useSessionSelector((state) => state.session?.role || null);
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
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', {
engine: 'flite',
voice: 'rms',
+90 -6
View File
@@ -9,9 +9,12 @@ import messageSound from '../assets/message.mp3';
import { useSettingsNamespace } from '../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
const ChatContext = createContext({
const CHAT_TIMELINE_DEFAULT = {
messages: [],
typing: [],
};
const CHAT_ACTIONS_DEFAULT = {
sendMessage: async () => {},
focusChat: () => {},
blurChat: () => {},
@@ -19,7 +22,31 @@ const ChatContext = createContext({
onInputFocus: () => {},
onInputBlur: () => {},
setTypingActive: () => {},
};
const CHAT_FOCUS_DEFAULT = {
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 }) {
@@ -91,7 +118,7 @@ export function ChatProvider({ children }) {
return () => {
socket.off('chat:message', handleMessage);
};
}, [playSound, session?.socketId, socket]);
}, [playSound, pushAlert, session?.socketId, socket]);
useEffect(() => {
function handleTyping(payload = {}) {
@@ -221,9 +248,18 @@ export function ChatProvider({ children }) {
const onInputFocus = useCallback(() => setIsChatFocused(true), []);
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,
typing,
}),
[messages, typing],
);
const actionsValue = useMemo(
() => ({
sendMessage,
focusChat,
blurChat,
@@ -231,14 +267,38 @@ export function ChatProvider({ children }) {
onInputFocus,
onInputBlur,
setTypingActive,
typing,
}),
[blurChat, focusChat, onInputBlur, onInputFocus, registerInputRef, sendMessage, setTypingActive],
);
const focusValue = useMemo(
() => ({
isChatFocused,
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() {
@@ -248,3 +308,27 @@ export function useChat() {
}
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.
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
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 { normalizeKeymapEntries, tokensForEvent } from '../keymapUtils.js';
import { isKeyboardCaptureLocked } from './keyboardCaptureLock.js';
@@ -105,7 +105,8 @@ export default function KeyboardInputManager() {
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
const dockAssist = useManualDockAssist();
const { homeAssistantSetState } = useSessionActions();
const { focusChat, isChatFocused } = useChat();
const { focusChat } = useChatActions();
const { isChatFocused } = useChatFocus();
const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
const { save: saveVideoSettings } = useSettingsNamespace('video', VIDEO_SETTINGS_DEFAULTS);
const keymap = useMemo(() => normalizeKeymapEntries(rawKeymap), [rawKeymap]);