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
@@ -64,7 +64,11 @@ import { readMeasuredWidth } from "@/hooks/use-container-width";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration";
import {
useAgentControlCommandCenterActions,
type AgentControlCommandCenterSource,
} from "@/command-center/agent-control-registration";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { isNative } from "@/constants/platform";
import {
resolveComposerControlDensity,
@@ -154,11 +158,28 @@ export interface DraftAgentControlsProps {
interface AgentControlsProps {
agentId: string;
serverId: string;
isPaneFocused: boolean;
onDropdownClose?: () => void;
isCompactLayout?: boolean;
}
function AgentControlCommandCenterRegistration({
sourceId,
enabled,
controls,
}: {
sourceId: string;
enabled: boolean;
controls: AgentControlCommandCenterSource;
}) {
const { isActiveComposer } = useComposerKeyboardScope();
useAgentControlCommandCenterActions({
sourceId,
enabled: enabled && isActiveComposer,
controls,
});
return null;
}
function findOptionLabel(
options: AgentControlOption[] | undefined,
selectedId: string | undefined,
@@ -1477,7 +1498,6 @@ function ThinkingComboboxOption({
export const AgentControls = memo(function AgentControls({
agentId,
serverId,
isPaneFocused,
onDropdownClose,
isCompactLayout,
}: AgentControlsProps) {
@@ -1649,10 +1669,8 @@ export const AgentControls = memo(function AgentControls({
[agentId, agentProvider, client, toast, updatePreferences],
);
useAgentControlCommandCenterActions({
sourceId: `agent:${serverId}:${agentId}`,
enabled: isPaneFocused && Boolean(client),
controls: {
const commandCenterControls = useMemo<AgentControlCommandCenterSource>(
() => ({
serverId,
ownerKey: agentId,
provider: agentProvider,
@@ -1673,8 +1691,31 @@ export const AgentControls = memo(function AgentControls({
list: agent?.features,
set: handleSetFeature,
},
},
});
}),
[
activeModelId,
agent?.features,
agentId,
agentModelSelectorProviders,
agentProvider,
commandCenterModes,
handleSelectCommandCenterModel,
handleSelectThinkingOption,
handleSetFeature,
modeProviderDefinitions,
modelSelection.selectedThinkingId,
modelSelection.thinkingOptions,
serverId,
],
);
const commandCenterRegistration = (
<AgentControlCommandCenterRegistration
sourceId={`agent:${serverId}:${agentId}`}
enabled={Boolean(client)}
controls={commandCenterControls}
/>
);
const handleModelSelectorOpen = useCallback(() => {
refetchSnapshotIfStale(agentProvider);
@@ -1692,30 +1733,33 @@ export const AgentControls = memo(function AgentControls({
}
return (
<ControlledAgentControls
provider={agent.provider}
modelSelectorProviders={agentModelSelectorProviders}
modelOptions={modelOptions}
selectedModelId={modelSelection.activeModelId ?? undefined}
onSelectModel={handleSelectModel}
agentProfiles={agentProfiles}
onApplyAgentProfile={agentProfiles?.applyProfile}
onEditAgentProfiles={handleEditAgentProfiles}
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
onSelectThinkingOption={handleSelectThinkingOption}
features={agent.features}
onSetFeature={handleSetFeature}
isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
onModelSelectorOpen={handleModelSelectorOpen}
onRetryModelProvider={handleRetryModelProvider}
isRetryingModelProvider={snapshotIsRefreshing}
onDropdownClose={onDropdownClose}
disabled={!client}
modeControl={modeControl}
modelSelectorServerId={serverId}
isCompactLayout={isCompactLayout}
/>
<>
{commandCenterRegistration}
<ControlledAgentControls
provider={agent.provider}
modelSelectorProviders={agentModelSelectorProviders}
modelOptions={modelOptions}
selectedModelId={modelSelection.activeModelId ?? undefined}
onSelectModel={handleSelectModel}
agentProfiles={agentProfiles}
onApplyAgentProfile={agentProfiles?.applyProfile}
onEditAgentProfiles={handleEditAgentProfiles}
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
onSelectThinkingOption={handleSelectThinkingOption}
features={agent.features}
onSetFeature={handleSetFeature}
isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
onModelSelectorOpen={handleModelSelectorOpen}
onRetryModelProvider={handleRetryModelProvider}
isRetryingModelProvider={snapshotIsRefreshing}
onDropdownClose={onDropdownClose}
disabled={!client}
modeControl={modeControl}
modelSelectorServerId={serverId}
isCompactLayout={isCompactLayout}
/>
</>
);
});
+93 -62
View File
@@ -96,7 +96,7 @@ import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispat
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { submitAgentInput } from "@/composer/submit";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope";
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { useAppSettings } from "@/hooks/use-settings";
import { isWeb, isNative } from "@/constants/platform";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
@@ -296,12 +296,11 @@ interface RenderLeftContentArgs {
serverId: string;
focusInput: () => void;
isCompactLayout: boolean;
isPaneFocused: boolean;
showAgentControls: boolean;
}
function renderLeftContent(args: RenderLeftContentArgs): ReactElement | null {
const { agentControls, agentId, serverId, focusInput, isCompactLayout, isPaneFocused } = args;
const { agentControls, agentId, serverId, focusInput, isCompactLayout } = args;
if (!args.showAgentControls) return null;
if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) {
return <DraftAgentControls {...agentControls} isCompactLayout={isCompactLayout} />;
@@ -310,7 +309,6 @@ function renderLeftContent(args: RenderLeftContentArgs): ReactElement | null {
<AgentControls
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
onDropdownClose={focusInput}
isCompactLayout={isCompactLayout}
/>
@@ -561,6 +559,68 @@ function dispatchComposerKeyboardAction(args: DispatchComposerKeyboardActionArgs
return true;
}
function ComposerKeyboardRegistration({
messageInputRef,
isAgentRunning,
isCancellingAgent,
isConnected,
handleCancelAgent,
focusMessageInputForKeyboardAction,
isMessageInputFocused,
handlerId,
}: Omit<DispatchComposerKeyboardActionArgs, "action" | "isPaneFocused"> & {
isMessageInputFocused: boolean;
handlerId: string;
}) {
const { isActiveComposer } = useComposerKeyboardScope();
const handleKeyboardAction = useCallback(
(action: KeyboardActionDefinition): boolean =>
dispatchComposerKeyboardAction({
action,
isPaneFocused: isActiveComposer,
messageInputRef,
isAgentRunning,
isCancellingAgent,
isConnected,
handleCancelAgent,
focusMessageInputForKeyboardAction,
}),
[
focusMessageInputForKeyboardAction,
handleCancelAgent,
isActiveComposer,
isAgentRunning,
isCancellingAgent,
isConnected,
messageInputRef,
],
);
useKeyboardActionHandler({
handlerId,
actions: [
"agent.interrupt",
"message-input.focus",
"message-input.send",
"message-input.dictation-toggle",
"message-input.dictation-cancel",
"message-input.dictation-confirm",
"message-input.voice-toggle",
"message-input.voice-mute-toggle",
],
enabled: isActiveComposer,
priority: resolveKeyboardPriority(isMessageInputFocused),
isActive: () => isActiveComposer,
handle: handleKeyboardAction,
});
return null;
}
function ComposerAutocomplete(props: React.ComponentProps<typeof AutocompletePopover>) {
const { isActiveComposer } = useComposerKeyboardScope();
return <AutocompletePopover {...props} visible={isActiveComposer && props.visible} />;
}
function resolveMessageInputPassthroughAction(
actionId: string,
): MessageInputKeyboardActionKind | null {
@@ -1052,12 +1112,23 @@ function ComposerVoiceModeButton({
);
}
export function Composer({ isPaneFocused, ...props }: ComposerProps) {
return (
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
<ComposerContent {...props} />
</ComposerKeyboardScopeProvider>
);
}
type ComposerContentProps = Omit<ComposerProps, "isPaneFocused">;
const ComposerContent = memo(ComposerContentImpl);
// oxlint-disable-next-line complexity
export function Composer({
function ComposerContentImpl({
agentId,
serverId,
workspaceId,
isPaneFocused,
onSubmitMessage,
onClientSlashCommand,
hasExternalContent = false,
@@ -1096,7 +1167,7 @@ export function Composer({
readOnly = false,
submitLabel,
placeholder,
}: ComposerProps) {
}: ComposerContentProps) {
const mode = resolveComposerInputMode(inputMode);
const { t } = useTranslation();
const buttonIconSize = resolveComposerButtonIconSize();
@@ -1682,46 +1753,6 @@ export function Composer({
focusMessageInputWithPlatformStrategy(messageInputRef);
}, []);
const handleKeyboardAction = useCallback(
(action: KeyboardActionDefinition): boolean =>
dispatchComposerKeyboardAction({
action,
isPaneFocused,
messageInputRef,
isAgentRunning,
isCancellingAgent,
isConnected,
handleCancelAgent,
focusMessageInputForKeyboardAction,
}),
[
focusMessageInputForKeyboardAction,
handleCancelAgent,
isAgentRunning,
isCancellingAgent,
isConnected,
isPaneFocused,
],
);
useKeyboardActionHandler({
handlerId: keyboardHandlerIdRef.current,
actions: [
"agent.interrupt",
"message-input.focus",
"message-input.send",
"message-input.dictation-toggle",
"message-input.dictation-cancel",
"message-input.dictation-confirm",
"message-input.voice-toggle",
"message-input.voice-mute-toggle",
],
enabled: isPaneFocused,
priority: resolveKeyboardPriority(isMessageInputFocused),
isActive: () => isPaneFocused,
handle: handleKeyboardAction,
});
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({
mode: "translate",
enabled: !externalKeyboardShift,
@@ -2032,18 +2063,9 @@ export function Composer({
serverId,
focusInput,
isCompactLayout,
isPaneFocused,
showAgentControls: mode.showAgentControls,
}),
[
agentControls,
agentId,
focusInput,
isCompactLayout,
isPaneFocused,
mode.showAgentControls,
serverId,
],
[agentControls, agentId, focusInput, isCompactLayout, mode.showAgentControls, serverId],
);
const handleAttachButtonRef = useCallback((node: View | null) => {
@@ -2169,10 +2191,20 @@ export function Composer({
const githubEmptyText = githubSearchResultsQuery.isFetching
? t("composer.github.searching")
: t("composer.github.noResults");
const autocompleteVisible = autocomplete.isVisible && isPaneFocused && mode.showAutocomplete;
const autocompleteVisible = autocomplete.isVisible && mode.showAutocomplete;
return (
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
<>
<ComposerKeyboardRegistration
handlerId={keyboardHandlerIdRef.current}
messageInputRef={messageInputRef}
isAgentRunning={isAgentRunning}
isCancellingAgent={isCancellingAgent}
isConnected={isConnected}
handleCancelAgent={handleCancelAgent}
focusMessageInputForKeyboardAction={focusMessageInputForKeyboardAction}
isMessageInputFocused={isMessageInputFocused}
/>
<Animated.View style={composerContainerStyle}>
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
{/* Input area */}
@@ -2182,7 +2214,7 @@ export function Composer({
{sendErrorNode}
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<AutocompletePopover
<ComposerAutocomplete
visible={autocompleteVisible}
anchorRef={messageInputContainerRef}
options={autocomplete.options}
@@ -2220,7 +2252,6 @@ export function Composer({
autoFocus={messageInputAutoFocus}
autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`}
disabled={isSubmitLoading}
isPaneFocused={isPaneFocused}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}
@@ -2267,7 +2298,7 @@ export function Composer({
</View>
</View>
</Animated.View>
</ComposerKeyboardScopeProvider>
</>
);
}
+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()) : "",