perf: reduce UI render fanout and scroll jitter
- Cut broad render fanout across the app by replacing shared-store whole-object subscriptions with leaf selectors, memoizing hot chrome boundaries, and isolating disabled global providers from live session/message state. This keeps header controls, composer toolbars, side panels, and other non-hot UI surfaces from repainting on every assistant update or keystroke. - Rework sidebar session ordering so recent, project groups, and worktree groups derive from one ordering source while avoiding streaming-time thrash. The sidebar now uses a stabilized session snapshot, preserves structural identity for unchanged rows, reads live row status/details per session, and applies a one-shot sort bump on idle->busy instead of continuously resorting during activity. - Fix chat/input scroll instability by separating viewport-resize handling from message-growth handling, disabling conflicting native scroll anchoring, and stopping textarea autosize from collapsing on every growth keystroke. This removes the multiline typing jiggle during streaming and reduces unnecessary composer rerenders. - Also gate voice context wiring behind voice-mode enablement and codify the learned render/scroll/order anti-patterns in AGENTS.md so future changes avoid the same classes of regressions.
This commit is contained in:
@@ -132,7 +132,19 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
||||
const sendMessage = useSessionUIStore((s) => s.sendMessage);
|
||||
const setPendingInputText = useInputStore((s) => s.setPendingInputText);
|
||||
const createSession = useSessionUIStore((s) => s.createSession);
|
||||
const { currentProviderId, currentModelId, currentAgentName, voiceModeEnabled, voiceProvider, speechRate, speechPitch, speechVolume, sayVoice, browserVoice, openaiVoice, summarizeVoiceConversation, summarizeCharacterThreshold } = useConfigStore();
|
||||
const currentProviderId = useConfigStore((state) => state.currentProviderId);
|
||||
const currentModelId = useConfigStore((state) => state.currentModelId);
|
||||
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
||||
const voiceModeEnabled = useConfigStore((state) => state.voiceModeEnabled);
|
||||
const voiceProvider = useConfigStore((state) => state.voiceProvider);
|
||||
const speechRate = useConfigStore((state) => state.speechRate);
|
||||
const speechPitch = useConfigStore((state) => state.speechPitch);
|
||||
const speechVolume = useConfigStore((state) => state.speechVolume);
|
||||
const sayVoice = useConfigStore((state) => state.sayVoice);
|
||||
const browserVoice = useConfigStore((state) => state.browserVoice);
|
||||
const openaiVoice = useConfigStore((state) => state.openaiVoice);
|
||||
const summarizeVoiceConversation = useConfigStore((state) => state.summarizeVoiceConversation);
|
||||
const summarizeCharacterThreshold = useConfigStore((state) => state.summarizeCharacterThreshold);
|
||||
|
||||
const shouldCheckOpenAIAvailability = voiceModeEnabled && voiceProvider === 'openai';
|
||||
const shouldCheckSayAvailability = voiceModeEnabled && voiceProvider === 'say';
|
||||
|
||||
@@ -457,7 +457,48 @@ export const useChatScrollManager = ({
|
||||
const container = scrollRef.current;
|
||||
if (!container || typeof ResizeObserver === 'undefined') return;
|
||||
|
||||
let lastScrollHeight = container.scrollHeight;
|
||||
let lastClientHeight = container.clientHeight;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
const nextScrollHeight = container.scrollHeight;
|
||||
const nextClientHeight = container.clientHeight;
|
||||
const scrollHeightChanged = nextScrollHeight !== lastScrollHeight;
|
||||
const clientHeightChanged = nextClientHeight !== lastClientHeight;
|
||||
|
||||
if (clientHeightChanged) {
|
||||
const previousDistanceFromBottom = Math.max(
|
||||
0,
|
||||
lastScrollHeight - lastScrollTopRef.current - lastClientHeight,
|
||||
);
|
||||
|
||||
if (isPinnedRef.current) {
|
||||
const targetScrollTop = Math.max(
|
||||
0,
|
||||
nextScrollHeight - nextClientHeight - previousDistanceFromBottom,
|
||||
);
|
||||
|
||||
if (Math.abs(container.scrollTop - targetScrollTop) > 0.5) {
|
||||
markProgrammaticScroll();
|
||||
container.scrollTop = targetScrollTop;
|
||||
lastScrollTopRef.current = targetScrollTop;
|
||||
}
|
||||
|
||||
lastScrollHeight = nextScrollHeight;
|
||||
lastClientHeight = nextClientHeight;
|
||||
updateScrollButtonVisibility();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
lastScrollHeight = nextScrollHeight;
|
||||
lastClientHeight = nextClientHeight;
|
||||
|
||||
if (clientHeightChanged && !scrollHeightChanged) {
|
||||
updateScrollButtonVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
schedulePinnedStateAndIndicators();
|
||||
});
|
||||
|
||||
@@ -474,7 +515,7 @@ export const useChatScrollManager = ({
|
||||
observer.disconnect();
|
||||
childObserver.disconnect();
|
||||
};
|
||||
}, [schedulePinnedStateAndIndicators]);
|
||||
}, [schedulePinnedStateAndIndicators, updateScrollButtonVisibility]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
|
||||
@@ -16,11 +16,9 @@ export const useEdgeSwipe = (options: EdgeSwipeOptions = {}) => {
|
||||
enabled = true,
|
||||
} = options;
|
||||
|
||||
const {
|
||||
isMobile,
|
||||
setSessionSwitcherOpen,
|
||||
isSessionSwitcherOpen,
|
||||
} = useUIStore();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
||||
const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen);
|
||||
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
||||
const touchEndRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessions } from '@/sync/sync-context';
|
||||
import { useSessionDirectory } from '@/sync/sync-context';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
/**
|
||||
* Hook that resolves the effective working directory for tabs (Git, Diff, Files, Terminal).
|
||||
@@ -18,7 +17,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
|
||||
export const useEffectiveDirectory = (): string | undefined => {
|
||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
||||
const sessions = useSessions();
|
||||
const currentSessionDirectory = useSessionDirectory(currentSessionId);
|
||||
const worktreeMap = useSessionUIStore((s) => s.worktreeMetadata);
|
||||
const fallbackDirectory = useDirectoryStore((s) => s.currentDirectory);
|
||||
|
||||
@@ -28,12 +27,8 @@ export const useEffectiveDirectory = (): string | undefined => {
|
||||
if (worktreeMetadata?.path) {
|
||||
return worktreeMetadata.path;
|
||||
}
|
||||
|
||||
const currentSession = sessions.find((session) => session.id === currentSessionId);
|
||||
type SessionWithDirectory = Session & { directory?: string };
|
||||
const sessionDirectory = (currentSession as SessionWithDirectory | undefined)?.directory;
|
||||
if (sessionDirectory) {
|
||||
return sessionDirectory;
|
||||
if (currentSessionDirectory) {
|
||||
return currentSessionDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,18 +24,16 @@ export interface UseMessageTTSReturn {
|
||||
export function useMessageTTS(): UseMessageTTSReturn {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
const {
|
||||
voiceProvider,
|
||||
speechRate,
|
||||
speechPitch,
|
||||
speechVolume,
|
||||
sayVoice,
|
||||
browserVoice,
|
||||
openaiVoice,
|
||||
summarizeMessageTTS,
|
||||
summarizeCharacterThreshold,
|
||||
showMessageTTSButtons,
|
||||
} = useConfigStore();
|
||||
const voiceProvider = useConfigStore((state) => state.voiceProvider);
|
||||
const speechRate = useConfigStore((state) => state.speechRate);
|
||||
const speechPitch = useConfigStore((state) => state.speechPitch);
|
||||
const speechVolume = useConfigStore((state) => state.speechVolume);
|
||||
const sayVoice = useConfigStore((state) => state.sayVoice);
|
||||
const browserVoice = useConfigStore((state) => state.browserVoice);
|
||||
const openaiVoice = useConfigStore((state) => state.openaiVoice);
|
||||
const summarizeMessageTTS = useConfigStore((state) => state.summarizeMessageTTS);
|
||||
const summarizeCharacterThreshold = useConfigStore((state) => state.summarizeCharacterThreshold);
|
||||
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
|
||||
|
||||
const shouldCheckOpenAIAvailability = showMessageTTSButtons && voiceProvider === 'openai';
|
||||
const shouldCheckSayAvailability = showMessageTTSButtons && voiceProvider === 'say';
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface ModelListItem {
|
||||
}
|
||||
|
||||
export const useModelLists = () => {
|
||||
const { providers } = useConfigStore();
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const favoriteModels = useUIStore((state) => state.favoriteModels);
|
||||
const recentModels = useUIStore((state) => state.recentModels);
|
||||
const hiddenModels = useUIStore((state) => state.hiddenModels);
|
||||
|
||||
@@ -125,7 +125,12 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Get current model, threshold, and max length from config store for summarization
|
||||
const { currentProviderId, currentModelId, summarizeCharacterThreshold, summarizeMaxLength, openaiApiKey, settingsZenModel } = useConfigStore();
|
||||
const currentProviderId = useConfigStore((state) => state.currentProviderId);
|
||||
const currentModelId = useConfigStore((state) => state.currentModelId);
|
||||
const summarizeCharacterThreshold = useConfigStore((state) => state.summarizeCharacterThreshold);
|
||||
const summarizeMaxLength = useConfigStore((state) => state.summarizeMaxLength);
|
||||
const openaiApiKey = useConfigStore((state) => state.openaiApiKey);
|
||||
const settingsZenModel = useConfigStore((state) => state.settingsZenModel);
|
||||
|
||||
// Check if server TTS is available
|
||||
const checkAvailability = useCallback(async (): Promise<boolean> => {
|
||||
|
||||
Reference in New Issue
Block a user