perf(app): keep warm workspace switching responsive (#3447)

* perf(app): reduce warm workspace switch work

Retained workspaces avoided mounts but pane activity still rerendered the full composer, message input, and agent-control presentation. Keep the activity contract intact while moving focus-sensitive behavior behind small context consumers, and add a Playwright benchmark that measures rapid retained workspace switching on one browser clock.

* perf(app): accelerate retained workspace switching

Avoid repeated retained-stream layout reconciliation and unrelated workspace structure recomputation. Use the lightweight route transition while the host is already focused, while preserving targeted root POP_TO for returns from app-wide routes where it prevents duplicate hidden decks.

* perf(app): defer composer focus during workspace switches

* perf(app): retain recently used workspaces longer

Keep up to ten workspace screens warm so normal multi-workspace navigation does not repeatedly rebuild recently used timelines. Inactive screens expire after ten minutes, while the active screen remains protected and capacity eviction stays least-recently-used.

* fix(app): reconcile retained timeline geometry

* fix(app): refresh retained reading position
This commit is contained in:
Mohamed Boudra
2026-08-16 12:40:28 +02:00
committed by GitHub
parent d60d92e909
commit d4435bd016
20 changed files with 1297 additions and 228 deletions
+25 -10
View File
@@ -54,6 +54,7 @@ import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { useComposerHeightMirror } from "./height-mirror";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import type { NativePastedFile } from "@/composer/native-pasted-image";
@@ -116,8 +117,6 @@ export interface MessageInputProps {
autoFocus?: boolean;
autoFocusKey?: string;
disabled?: boolean;
/** True when this composer's pane is focused. Used to gate global hotkeys and stop dictation when hidden. */
isPaneFocused?: boolean;
/** Content to render on the left side of the composer toolbar (e.g., AgentControls) */
leftContent?: React.ReactNode;
/** Content to render on the right side before the voice button (e.g., context window meter) */
@@ -496,11 +495,26 @@ function useAutoFocusOnWebEffect(
const active = typeof document !== "undefined" ? document.activeElement : null;
return Boolean(element) && active === element;
},
deferInitialAttempt: true,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoFocus, autoFocusKey]);
}
function MessageInputAutoFocus({
enabled,
autoFocusKey,
textInputRef,
}: {
enabled: boolean;
autoFocusKey: string | undefined;
textInputRef: React.MutableRefObject<ComposerTextInputHandle | null>;
}) {
const { isActiveComposer } = useComposerKeyboardScope();
useAutoFocusOnWebEffect(textInputRef, enabled && isActiveComposer, autoFocusKey);
return null;
}
function MessageInputOverlay({
showDictationOverlay,
showRealtimeOverlay,
@@ -580,7 +594,8 @@ function FocusHint({
focusInputKeys: ShortcutChord | null | undefined;
label: string;
}) {
if (!visible || !focusInputKeys || !label.trim()) return null;
const { isActiveComposer } = useComposerKeyboardScope();
if (!isActiveComposer || !visible || !focusInputKeys || !label.trim()) return null;
return (
<Text style={styles.focusHintText} pointerEvents="none">
{label}
@@ -1030,7 +1045,6 @@ interface ResolvedMessageInputProps {
autoFocus: boolean;
autoFocusKey: string | undefined;
disabled: boolean;
isPaneFocused: boolean;
leftContent: React.ReactNode;
beforeVoiceContent: React.ReactNode;
rightContent: React.ReactNode;
@@ -1078,7 +1092,6 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
autoFocus: props.autoFocus ?? false,
autoFocusKey: props.autoFocusKey,
disabled: props.disabled ?? false,
isPaneFocused: props.isPaneFocused ?? true,
leftContent: props.leftContent,
beforeVoiceContent: props.beforeVoiceContent,
rightContent: props.rightContent,
@@ -1134,7 +1147,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
autoFocus,
autoFocusKey,
disabled,
isPaneFocused,
leftContent,
beforeVoiceContent,
rightContent,
@@ -1237,8 +1249,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
};
}, [onFocusChange]);
useAutoFocusOnWebEffect(textInputRef, autoFocus, autoFocusKey);
const handleDictationTranscript = useCallback(
(text: string, _meta: { requestId: string }) => {
const autoSend = sendAfterTranscriptRef.current;
@@ -1738,6 +1748,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
return (
<View ref={rootRef} style={styles.container} testID="message-input-root">
<MessageInputAutoFocus
enabled={autoFocus}
autoFocusKey={autoFocusKey}
textInputRef={textInputRef}
/>
{/* Regular input */}
<View
ref={inputWrapperRef}
@@ -1759,13 +1774,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onBlur={handleInputBlur}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true}
autoFocus={isWeb && autoFocus}
autoFocus={false}
onContentSizeChange={handleContentSizeChange}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
onPasteImages={onPasteImages}
onPasteError={handlePasteError}
focusHintVisible={isWeb && isPaneFocused && !isInputFocused && !value}
focusHintVisible={isWeb && !isInputFocused && !value}
focusInputKeys={focusInputKeys}
focusHintLabel={t("composer.input.focusHint", {
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",