Reduce workspace render churn
This commit is contained in:
@@ -206,6 +206,11 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keyed by history array identity; inner key encodes the inputs that affect history layout.
|
||||||
|
// History layout is stable across text-chunk flushes because the liveHead boundary item's
|
||||||
|
// kind and id don't change when only its text grows.
|
||||||
|
const historyLayoutCache = new WeakMap<StreamItem[], Map<string, StreamLayoutItem[]>>();
|
||||||
|
|
||||||
export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||||
const auxiliaryTurnFooter = resolveAuxiliaryTurnFooter(input);
|
const auxiliaryTurnFooter = resolveAuxiliaryTurnFooter(input);
|
||||||
const historyBoundaryIndex = input.strategy.getHistoryLiveBoundaryIndex(input.history);
|
const historyBoundaryIndex = input.strategy.getHistoryLiveBoundaryIndex(input.history);
|
||||||
@@ -215,17 +220,46 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
|||||||
const liveHeadBoundaryItem =
|
const liveHeadBoundaryItem =
|
||||||
liveHeadBoundaryIndex === null ? null : (input.liveHead[liveHeadBoundaryIndex] ?? null);
|
liveHeadBoundaryIndex === null ? null : (input.liveHead[liveHeadBoundaryIndex] ?? null);
|
||||||
const frameOrder = input.strategy.getFrameChildOrder();
|
const frameOrder = input.strategy.getFrameChildOrder();
|
||||||
const history = layoutSegment({
|
|
||||||
strategy: input.strategy,
|
let history: StreamLayoutItem[];
|
||||||
agentStatus: input.agentStatus,
|
if (input.history.length > 0) {
|
||||||
items: input.history,
|
// The cache key encodes every input that can change history layout. liveHeadBoundaryItem.id
|
||||||
timingByAssistantId: input.timingByAssistantId,
|
// and .kind are stable across text-only flushes (text growth doesn't change what kind of
|
||||||
auxiliaryTurnFooter,
|
// item borders history), so cached layout stays valid between flushes.
|
||||||
frameOrder,
|
const historyCacheKey = [
|
||||||
boundaryIndex: historyBoundaryIndex,
|
input.agentStatus,
|
||||||
boundaryAboveItem: null,
|
frameOrder,
|
||||||
boundaryBelowItem: liveHeadBoundaryItem,
|
historyBoundaryIndex ?? "null",
|
||||||
});
|
liveHeadBoundaryItem?.id ?? "null",
|
||||||
|
liveHeadBoundaryItem?.kind ?? "null",
|
||||||
|
auxiliaryTurnFooter?.itemId ?? "null",
|
||||||
|
].join(":");
|
||||||
|
let byKey = historyLayoutCache.get(input.history);
|
||||||
|
if (!byKey) {
|
||||||
|
byKey = new Map();
|
||||||
|
historyLayoutCache.set(input.history, byKey);
|
||||||
|
}
|
||||||
|
const cached = byKey.get(historyCacheKey);
|
||||||
|
if (cached) {
|
||||||
|
history = cached;
|
||||||
|
} else {
|
||||||
|
history = layoutSegment({
|
||||||
|
strategy: input.strategy,
|
||||||
|
agentStatus: input.agentStatus,
|
||||||
|
items: input.history,
|
||||||
|
timingByAssistantId: input.timingByAssistantId,
|
||||||
|
auxiliaryTurnFooter,
|
||||||
|
frameOrder,
|
||||||
|
boundaryIndex: historyBoundaryIndex,
|
||||||
|
boundaryAboveItem: null,
|
||||||
|
boundaryBelowItem: liveHeadBoundaryItem,
|
||||||
|
});
|
||||||
|
byKey.set(historyCacheKey, history);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
history = [];
|
||||||
|
}
|
||||||
|
|
||||||
const liveHead = layoutSegment({
|
const liveHead = layoutSegment({
|
||||||
strategy: input.strategy,
|
strategy: input.strategy,
|
||||||
agentStatus: input.agentStatus,
|
agentStatus: input.agentStatus,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, {
|
|||||||
forwardRef,
|
forwardRef,
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -80,6 +81,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
|
|||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||||
|
import { MountedTabActiveContext } from "@/components/split-container";
|
||||||
|
|
||||||
function renderLiveAuxiliaryNode(input: {
|
function renderLiveAuxiliaryNode(input: {
|
||||||
pendingPermissions: ReactNode;
|
pendingPermissions: ReactNode;
|
||||||
@@ -364,15 +366,29 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
|
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Freeze stream data while this tab slot is hidden to prevent offscreen FlatList
|
||||||
|
// cell-window renders on every 48ms flush from background agents.
|
||||||
|
// When isActive flips back to true, the context change triggers a re-render and
|
||||||
|
// the component reads the current (fresh) streamItems/streamHead from props.
|
||||||
|
const isActive = useContext(MountedTabActiveContext);
|
||||||
|
const frozenStreamItemsRef = useRef(streamItems);
|
||||||
|
const frozenStreamHeadRef = useRef(streamHead);
|
||||||
|
if (isActive) {
|
||||||
|
frozenStreamItemsRef.current = streamItems;
|
||||||
|
frozenStreamHeadRef.current = streamHead;
|
||||||
|
}
|
||||||
|
const effectiveStreamItems = isActive ? streamItems : frozenStreamItemsRef.current;
|
||||||
|
const effectiveStreamHead = isActive ? streamHead : frozenStreamHeadRef.current;
|
||||||
|
|
||||||
const baseRenderModel = useMemo(() => {
|
const baseRenderModel = useMemo(() => {
|
||||||
return buildAgentStreamRenderModel({
|
return buildAgentStreamRenderModel({
|
||||||
agentStatus: agent.status,
|
agentStatus: agent.status,
|
||||||
tail: streamItems,
|
tail: effectiveStreamItems,
|
||||||
head: streamHead ?? EMPTY_STREAM_HEAD,
|
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
|
||||||
platform: isWeb ? "web" : "native",
|
platform: isWeb ? "web" : "native",
|
||||||
isMobileBreakpoint: isMobile,
|
isMobileBreakpoint: isMobile,
|
||||||
});
|
});
|
||||||
}, [agent.status, isMobile, streamHead, streamItems]);
|
}, [agent.status, isMobile, effectiveStreamHead, effectiveStreamItems]);
|
||||||
const streamLayout = useMemo(
|
const streamLayout = useMemo(
|
||||||
() =>
|
() =>
|
||||||
layoutStream({
|
layoutStream({
|
||||||
@@ -684,14 +700,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
(item) => renderHistoryRow(item),
|
(item) => renderHistoryRow(item),
|
||||||
[renderHistoryRow],
|
[renderHistoryRow],
|
||||||
);
|
);
|
||||||
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
|
// useStableEvent keeps the function reference stable across flushes.
|
||||||
(item) =>
|
// layoutLiveHeadItemById and renderStreamItem are read from the ref at call time,
|
||||||
|
// so the live-head render always uses the latest layout without causing renderers
|
||||||
|
// to be a new object on every text-chunk flush.
|
||||||
|
const renderLiveHeadRow: StreamSegmentRenderers["renderLiveHeadRow"] = useStableEvent(
|
||||||
|
(item: StreamItem) =>
|
||||||
renderLiveHeadStreamItem({
|
renderLiveHeadStreamItem({
|
||||||
item,
|
item,
|
||||||
layoutItemById: layoutLiveHeadItemById,
|
layoutItemById: layoutLiveHeadItemById,
|
||||||
renderStreamItem,
|
renderStreamItem,
|
||||||
}),
|
}),
|
||||||
[layoutLiveHeadItemById, renderStreamItem],
|
|
||||||
);
|
);
|
||||||
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => {
|
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => {
|
||||||
return renderLiveAuxiliaryNode({
|
return renderLiveAuxiliaryNode({
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ import { Shortcut } from "@/components/ui/shortcut";
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
import {
|
||||||
|
useSidebarAnimation,
|
||||||
|
useSidebarSettledGeneration,
|
||||||
|
} from "@/contexts/sidebar-animation-context";
|
||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
||||||
@@ -615,12 +618,12 @@ function MobileSidebar({
|
|||||||
windowWidth,
|
windowWidth,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
settledGeneration,
|
|
||||||
isGesturing,
|
isGesturing,
|
||||||
mobilePanelState,
|
mobilePanelState,
|
||||||
gestureAnimatingRef,
|
gestureAnimatingRef,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
} = useSidebarAnimation();
|
} = useSidebarAnimation();
|
||||||
|
const settledGeneration = useSidebarSettledGeneration();
|
||||||
const closeTouchStartX = useSharedValue(0);
|
const closeTouchStartX = useSharedValue(0);
|
||||||
const closeTouchStartY = useSharedValue(0);
|
const closeTouchStartY = useSharedValue(0);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Fragment,
|
Fragment,
|
||||||
|
createContext,
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -73,6 +74,10 @@ import { RenderProfile } from "@/utils/render-profiler";
|
|||||||
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
|
// true = this tab slot is the active (visible) tab; false = mounted but hidden.
|
||||||
|
// Defaults to true so consumers outside a slot (e.g. web preview) are unaffected.
|
||||||
|
export const MountedTabActiveContext = createContext<boolean>(true);
|
||||||
|
|
||||||
interface SplitContainerProps {
|
interface SplitContainerProps {
|
||||||
layout: WorkspaceLayout;
|
layout: WorkspaceLayout;
|
||||||
workspaceKey: string;
|
workspaceKey: string;
|
||||||
@@ -219,14 +224,16 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<RenderProfile id={`DesktopMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
|
<RenderProfile id={`DesktopMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
|
||||||
<View style={wrapperStyle}>
|
<MountedTabActiveContext value={isVisible}>
|
||||||
<WorkspacePaneContent
|
<View style={wrapperStyle}>
|
||||||
content={content}
|
<WorkspacePaneContent
|
||||||
isWorkspaceFocused={isWorkspaceFocused}
|
content={content}
|
||||||
isPaneFocused={isPaneFocused}
|
isWorkspaceFocused={isWorkspaceFocused}
|
||||||
onFocusPane={handleFocusPane}
|
isPaneFocused={isPaneFocused}
|
||||||
/>
|
onFocusPane={handleFocusPane}
|
||||||
</View>
|
/>
|
||||||
|
</View>
|
||||||
|
</MountedTabActiveContext>
|
||||||
</RenderProfile>
|
</RenderProfile>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,14 +20,20 @@ export function WorkspaceShortcutTargetsSubscriber({
|
|||||||
serverId: string | null;
|
serverId: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { projects } = useSidebarWorkspacesList({ serverId, enabled });
|
const { projects } = useSidebarWorkspacesList({ serverId, enabled });
|
||||||
const statusWorkspaces = useStatusModeWorkspaceEntries({
|
// groupMode must be resolved before gating the status-mode subscriptions below.
|
||||||
serverId: enabled ? serverId : null,
|
|
||||||
projects,
|
|
||||||
});
|
|
||||||
const projectNamesByKey = useProjectNamesMap(enabled ? serverId : null);
|
|
||||||
const groupMode = useSidebarViewStore((state) =>
|
const groupMode = useSidebarViewStore((state) =>
|
||||||
enabled && serverId ? state.getGroupMode(serverId) : "project",
|
enabled && serverId ? state.getGroupMode(serverId) : "project",
|
||||||
);
|
);
|
||||||
|
// Only subscribe to agents/workspaces when the status-group view is actually active.
|
||||||
|
// In project mode (the default), these subscriptions would fire on every agent update
|
||||||
|
// (agents Map identity is replaced on every status transition) with no effect on
|
||||||
|
// the shortcut targets, causing ~15-46 wasted re-renders per agent switch.
|
||||||
|
const isStatusMode = enabled && groupMode === "status";
|
||||||
|
const statusWorkspaces = useStatusModeWorkspaceEntries({
|
||||||
|
serverId: isStatusMode ? serverId : null,
|
||||||
|
projects,
|
||||||
|
});
|
||||||
|
const projectNamesByKey = useProjectNamesMap(isStatusMode ? serverId : null);
|
||||||
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
|
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
|
||||||
(state) => state.collapsedProjectKeys,
|
(state) => state.collapsedProjectKeys,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ interface SidebarAnimationContextValue {
|
|||||||
animateToClose: () => void;
|
animateToClose: () => void;
|
||||||
startMobilePanelTransition: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
|
startMobilePanelTransition: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
|
||||||
settleMobilePanel: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
|
settleMobilePanel: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
|
||||||
settledGeneration: number;
|
|
||||||
isGesturing: SharedValue<boolean>;
|
isGesturing: SharedValue<boolean>;
|
||||||
mobileVisualPanel: SharedValue<number>;
|
mobileVisualPanel: SharedValue<number>;
|
||||||
mobilePanelState: SharedValue<number>;
|
mobilePanelState: SharedValue<number>;
|
||||||
@@ -61,6 +60,10 @@ interface SidebarAnimationContextValue {
|
|||||||
|
|
||||||
const SidebarAnimationContext = createContext<SidebarAnimationContextValue | null>(null);
|
const SidebarAnimationContext = createContext<SidebarAnimationContextValue | null>(null);
|
||||||
|
|
||||||
|
// Separate context so that settle-driven re-renders only reach MobileSidebar,
|
||||||
|
// not every other useSidebarAnimation() consumer.
|
||||||
|
const SidebarSettledGenerationContext = createContext<number>(0);
|
||||||
|
|
||||||
function getMobileVisualPanel(mobileView: "agent" | "agent-list" | "file-explorer"): number {
|
function getMobileVisualPanel(mobileView: "agent" | "agent-list" | "file-explorer"): number {
|
||||||
if (mobileView === "agent-list") {
|
if (mobileView === "agent-list") {
|
||||||
return MOBILE_VISUAL_PANEL_AGENT_LIST;
|
return MOBILE_VISUAL_PANEL_AGENT_LIST;
|
||||||
@@ -357,7 +360,6 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
animateToClose,
|
animateToClose,
|
||||||
startMobilePanelTransition,
|
startMobilePanelTransition,
|
||||||
settleMobilePanel,
|
settleMobilePanel,
|
||||||
settledGeneration,
|
|
||||||
isGesturing,
|
isGesturing,
|
||||||
mobileVisualPanel,
|
mobileVisualPanel,
|
||||||
mobilePanelState,
|
mobilePanelState,
|
||||||
@@ -373,7 +375,6 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
animateToClose,
|
animateToClose,
|
||||||
startMobilePanelTransition,
|
startMobilePanelTransition,
|
||||||
settleMobilePanel,
|
settleMobilePanel,
|
||||||
settledGeneration,
|
|
||||||
isGesturing,
|
isGesturing,
|
||||||
mobileVisualPanel,
|
mobileVisualPanel,
|
||||||
mobilePanelState,
|
mobilePanelState,
|
||||||
@@ -384,7 +385,11 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarAnimationContext.Provider value={value}>{children}</SidebarAnimationContext.Provider>
|
<SidebarAnimationContext.Provider value={value}>
|
||||||
|
<SidebarSettledGenerationContext.Provider value={settledGeneration}>
|
||||||
|
{children}
|
||||||
|
</SidebarSettledGenerationContext.Provider>
|
||||||
|
</SidebarAnimationContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,3 +400,7 @@ export function useSidebarAnimation() {
|
|||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSidebarSettledGeneration() {
|
||||||
|
return useContext(SidebarSettledGenerationContext);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
createContext,
|
createContext,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useSyncExternalStore,
|
useSyncExternalStore,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -57,22 +58,27 @@ export function useVoice() {
|
|||||||
export function useVoiceOptional(): VoiceContextValue | null {
|
export function useVoiceOptional(): VoiceContextValue | null {
|
||||||
const runtime = useContext(VoiceRuntimeContext);
|
const runtime = useContext(VoiceRuntimeContext);
|
||||||
const snapshot = useSyncExternalStore(
|
const snapshot = useSyncExternalStore(
|
||||||
runtime ? runtime.subscribe.bind(runtime) : noopSubscribe,
|
runtime ? runtime.subscribe : noopSubscribe,
|
||||||
runtime ? runtime.getSnapshot.bind(runtime) : getEmptySnapshot,
|
runtime ? runtime.getSnapshot : getEmptySnapshot,
|
||||||
runtime ? runtime.getSnapshot.bind(runtime) : getEmptySnapshot,
|
runtime ? runtime.getSnapshot : getEmptySnapshot,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!runtime) {
|
// Methods on the runtime object literal close over factory-local state; they
|
||||||
return null;
|
// don't use `this`, so no binding is needed. Memoising on [snapshot, runtime]
|
||||||
}
|
// keeps the returned object reference stable across re-renders that don't
|
||||||
|
// change either, preventing downstream memo/useMemo misses.
|
||||||
return {
|
return useMemo(() => {
|
||||||
...snapshot,
|
if (!runtime) {
|
||||||
startVoice: runtime.startVoice.bind(runtime),
|
return null;
|
||||||
stopVoice: runtime.stopVoice.bind(runtime),
|
}
|
||||||
isVoiceModeForAgent: runtime.isVoiceModeForAgent.bind(runtime),
|
return {
|
||||||
toggleMute: runtime.toggleMute.bind(runtime),
|
...snapshot,
|
||||||
};
|
startVoice: runtime.startVoice,
|
||||||
|
stopVoice: runtime.stopVoice,
|
||||||
|
isVoiceModeForAgent: runtime.isVoiceModeForAgent,
|
||||||
|
toggleMute: runtime.toggleMute,
|
||||||
|
};
|
||||||
|
}, [snapshot, runtime]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useVoiceTelemetry() {
|
export function useVoiceTelemetry() {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useCallback, useSyncExternalStore } from "react";
|
import { useMemo, useCallback, useRef, useSyncExternalStore } from "react";
|
||||||
|
import equal from "fast-deep-equal";
|
||||||
import { useShallow } from "zustand/shallow";
|
import { useShallow } from "zustand/shallow";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||||
@@ -44,6 +45,13 @@ export function useAggregatedAgents(options?: {
|
|||||||
runtime.refreshAllAgentDirectories();
|
runtime.refreshAllAgentDirectories();
|
||||||
}, [runtime]);
|
}, [runtime]);
|
||||||
|
|
||||||
|
// Keyed by "serverId:agentId" — reuse the previous AggregatedAgent object when
|
||||||
|
// none of its fields changed, so downstream memo/shallow comparisons can bail early.
|
||||||
|
const prevAgentsRef = useRef<Map<string, AggregatedAgent>>(new Map());
|
||||||
|
// Preserved sorted array — returned as-is when every element kept its identity
|
||||||
|
// and order, so callers using reference equality skip re-renders entirely.
|
||||||
|
const prevSortedRef = useRef<AggregatedAgent[]>([]);
|
||||||
|
|
||||||
const result = useMemo(() => {
|
const result = useMemo(() => {
|
||||||
// runtimeVersion is referenced so the memo recomputes when runtime state changes.
|
// runtimeVersion is referenced so the memo recomputes when runtime state changes.
|
||||||
void runtimeVersion;
|
void runtimeVersion;
|
||||||
@@ -79,7 +87,11 @@ export function useAggregatedAgents(options?: {
|
|||||||
createdAt: agent.createdAt,
|
createdAt: agent.createdAt,
|
||||||
labels: agent.labels,
|
labels: agent.labels,
|
||||||
};
|
};
|
||||||
allAgents.push(nextAgent);
|
const cacheKey = `${serverId}:${agent.id}`;
|
||||||
|
const prev = prevAgentsRef.current.get(cacheKey);
|
||||||
|
// Preserve object identity when fields are unchanged so callers can use
|
||||||
|
// reference equality (useShallow, memo) to skip re-renders.
|
||||||
|
allAgents.push(prev !== undefined && equal(prev, nextAgent) ? prev : nextAgent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,8 +110,25 @@ export function useAggregatedAgents(options?: {
|
|||||||
return rightTime - leftTime;
|
return rightTime - leftTime;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update the identity cache for the next render pass.
|
||||||
|
const nextCache = new Map<string, AggregatedAgent>();
|
||||||
|
for (const agent of allAgents) {
|
||||||
|
nextCache.set(`${agent.serverId}:${agent.id}`, agent);
|
||||||
|
}
|
||||||
|
prevAgentsRef.current = nextCache;
|
||||||
|
|
||||||
|
// If every element kept its reference identity and the order is the same,
|
||||||
|
// return the previous array so downstream reference comparisons can bail.
|
||||||
|
const prevSorted = prevSortedRef.current;
|
||||||
|
const stableAgents =
|
||||||
|
allAgents.length === prevSorted.length &&
|
||||||
|
allAgents.every((agent, i) => agent === prevSorted[i])
|
||||||
|
? prevSorted
|
||||||
|
: allAgents;
|
||||||
|
prevSortedRef.current = stableAgents;
|
||||||
|
|
||||||
// Check if we have any cached data
|
// Check if we have any cached data
|
||||||
const hasAnyData = allAgents.length > 0;
|
const hasAnyData = stableAgents.length > 0;
|
||||||
|
|
||||||
// Align list loading with the runtime directory-sync machine.
|
// Align list loading with the runtime directory-sync machine.
|
||||||
const isLoading = daemons.some((daemon) => {
|
const isLoading = daemons.some((daemon) => {
|
||||||
@@ -111,7 +140,7 @@ export function useAggregatedAgents(options?: {
|
|||||||
const isRevalidating = isLoading && hasAnyData;
|
const isRevalidating = isLoading && hasAnyData;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
agents: allAgents,
|
agents: stableAgents,
|
||||||
isLoading,
|
isLoading,
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
isRevalidating,
|
isRevalidating,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||||
|
import equal from "fast-deep-equal";
|
||||||
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
|
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
|
||||||
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
|
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
import { selectWorkspace, workspaceEqualityFns } from "@/stores/session-store-hooks/selectors";
|
||||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||||
import { selectPrHintFromStatus } from "@/git/use-pr-status-query";
|
import { selectPrHintFromStatus } from "@/git/use-pr-status-query";
|
||||||
@@ -147,22 +149,31 @@ export function useSidebarWorkspaceEntry(
|
|||||||
serverId: string | null,
|
serverId: string | null,
|
||||||
workspaceId: string | null,
|
workspaceId: string | null,
|
||||||
): SidebarWorkspaceEntry | null {
|
): SidebarWorkspaceEntry | null {
|
||||||
const pendingCreateAttempts = useCreateFlowStore((state) => state.pendingByDraftId);
|
// Deep-compare so that adding/removing unrelated pending creates doesn't re-render this row.
|
||||||
const agents = useSessionStore((state) =>
|
const pendingCreateAttempts = useStoreWithEqualityFn(
|
||||||
serverId ? state.sessions[serverId]?.agents : undefined,
|
useCreateFlowStore,
|
||||||
|
(state) => state.pendingByDraftId,
|
||||||
|
workspaceEqualityFns.deep,
|
||||||
);
|
);
|
||||||
const projectWorkspaceEntry = useCallback(
|
|
||||||
(workspace: WorkspaceDescriptor): SidebarWorkspaceEntry =>
|
// Single subscription: reads workspace + agents together, computes the full entry, and
|
||||||
createSidebarWorkspaceEntry({
|
// deep-compares the output. Agents-Map identity churn (setAgents replaces the Map on every
|
||||||
|
// status transition) never causes a React re-render unless the derived entry actually changes.
|
||||||
|
return useStoreWithEqualityFn(
|
||||||
|
useSessionStore,
|
||||||
|
(state) => {
|
||||||
|
const workspace = selectWorkspace(state, serverId, workspaceId);
|
||||||
|
if (!workspace) return null;
|
||||||
|
const agents = serverId ? state.sessions[serverId]?.agents : undefined;
|
||||||
|
return createSidebarWorkspaceEntry({
|
||||||
serverId: serverId ?? "",
|
serverId: serverId ?? "",
|
||||||
workspace,
|
workspace,
|
||||||
pendingCreateAttempts,
|
pendingCreateAttempts,
|
||||||
agents,
|
agents,
|
||||||
}),
|
});
|
||||||
[agents, pendingCreateAttempts, serverId],
|
},
|
||||||
|
equal,
|
||||||
);
|
);
|
||||||
|
|
||||||
return useWorkspaceFields(serverId, workspaceId, projectWorkspaceEntry);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_ORDER: string[] = [];
|
const EMPTY_ORDER: string[] = [];
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ import type { UserComposerAttachment } from "@/attachments/types";
|
|||||||
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
||||||
import type { ImageAttachment } from "@/composer/types";
|
import type { ImageAttachment } from "@/composer/types";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { ToastViewport, useToastHost } from "@/components/toast-host";
|
import {
|
||||||
|
ToastViewport,
|
||||||
|
useToastHost,
|
||||||
|
type ToastApi,
|
||||||
|
type ToastState,
|
||||||
|
} from "@/components/toast-host";
|
||||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||||
import {
|
import {
|
||||||
useWorkspaceAttachments,
|
useWorkspaceAttachments,
|
||||||
@@ -682,7 +687,7 @@ function ChatAgentContent({
|
|||||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const panelToast = useToastHost();
|
const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost();
|
||||||
const { isArchivingAgent } = useArchiveAgent();
|
const { isArchivingAgent } = useArchiveAgent();
|
||||||
const streamViewRef = useRef<AgentStreamViewHandle>(null);
|
const streamViewRef = useRef<AgentStreamViewHandle>(null);
|
||||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||||
@@ -737,10 +742,10 @@ function ChatAgentContent({
|
|||||||
addFilesRef.current?.(uploaded);
|
addFilesRef.current?.(uploaded);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AgentPanel] Failed to upload dropped files:", error);
|
console.error("[AgentPanel] Failed to upload dropped files:", error);
|
||||||
panelToast.api.error(error instanceof Error ? error.message : "Failed to upload file");
|
toastApi.error(error instanceof Error ? error.message : "Failed to upload file");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[client, isConnected, panelToast.api],
|
[client, isConnected, toastApi],
|
||||||
);
|
);
|
||||||
|
|
||||||
const agentState = useSessionStore(
|
const agentState = useSessionStore(
|
||||||
@@ -841,7 +846,7 @@ function ChatAgentContent({
|
|||||||
if (connectionStatus === "online") {
|
if (connectionStatus === "online") {
|
||||||
if (reconnectToastArmedRef.current) {
|
if (reconnectToastArmedRef.current) {
|
||||||
reconnectToastArmedRef.current = false;
|
reconnectToastArmedRef.current = false;
|
||||||
panelToast.dismiss();
|
dismissToast();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -850,12 +855,12 @@ function ChatAgentContent({
|
|||||||
}
|
}
|
||||||
if (!reconnectToastArmedRef.current) {
|
if (!reconnectToastArmedRef.current) {
|
||||||
reconnectToastArmedRef.current = true;
|
reconnectToastArmedRef.current = true;
|
||||||
panelToast.api.show(t("agentPanel.states.reconnecting"), {
|
toastApi.show(t("agentPanel.states.reconnecting"), {
|
||||||
durationMs: null,
|
durationMs: null,
|
||||||
testID: "agent-reconnecting-toast",
|
testID: "agent-reconnecting-toast",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [connectionStatus, panelToast, t]);
|
}, [connectionStatus, dismissToast, toastApi, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPaneFocused || !agentId || !isConnected || !hasSession) {
|
if (!isPaneFocused || !agentId || !isConnected || !hasSession) {
|
||||||
@@ -1089,7 +1094,9 @@ function ChatAgentContent({
|
|||||||
effectiveAgent={effectiveAgent}
|
effectiveAgent={effectiveAgent}
|
||||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||||
panelToast={panelToast}
|
toastApi={toastApi}
|
||||||
|
toast={toastState}
|
||||||
|
dismiss={dismissToast}
|
||||||
streamViewRef={streamViewRef}
|
streamViewRef={streamViewRef}
|
||||||
animatedContentStyle={animatedContentStyle}
|
animatedContentStyle={animatedContentStyle}
|
||||||
handleFilesDropped={handleFilesDropped}
|
handleFilesDropped={handleFilesDropped}
|
||||||
@@ -1100,7 +1107,8 @@ function ChatAgentContent({
|
|||||||
handleMessageSent={handleMessageSent}
|
handleMessageSent={handleMessageSent}
|
||||||
showHistorySyncOverlay={showHistorySyncOverlay}
|
showHistorySyncOverlay={showHistorySyncOverlay}
|
||||||
cwd={agentCwd}
|
cwd={agentCwd}
|
||||||
attentionController={attentionController}
|
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||||
|
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
||||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -1124,7 +1132,8 @@ function isImagePath(path: string): boolean {
|
|||||||
]);
|
]);
|
||||||
return imageExts.has(ext);
|
return imageExts.has(ext);
|
||||||
}
|
}
|
||||||
function ChatAgentReadyContent({
|
|
||||||
|
const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
isPaneFocused,
|
isPaneFocused,
|
||||||
@@ -1133,7 +1142,9 @@ function ChatAgentReadyContent({
|
|||||||
effectiveAgent,
|
effectiveAgent,
|
||||||
routeBottomAnchorRequest,
|
routeBottomAnchorRequest,
|
||||||
hasAppliedAuthoritativeHistory,
|
hasAppliedAuthoritativeHistory,
|
||||||
panelToast,
|
toastApi,
|
||||||
|
toast,
|
||||||
|
dismiss,
|
||||||
streamViewRef,
|
streamViewRef,
|
||||||
animatedContentStyle,
|
animatedContentStyle,
|
||||||
handleFilesDropped,
|
handleFilesDropped,
|
||||||
@@ -1144,7 +1155,8 @@ function ChatAgentReadyContent({
|
|||||||
handleMessageSent,
|
handleMessageSent,
|
||||||
showHistorySyncOverlay,
|
showHistorySyncOverlay,
|
||||||
cwd,
|
cwd,
|
||||||
attentionController,
|
onAttentionInputFocus,
|
||||||
|
onAttentionPromptSend,
|
||||||
onOpenWorkspaceFile,
|
onOpenWorkspaceFile,
|
||||||
}: {
|
}: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -1155,7 +1167,9 @@ function ChatAgentReadyContent({
|
|||||||
effectiveAgent: AgentScreenAgent;
|
effectiveAgent: AgentScreenAgent;
|
||||||
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
||||||
hasAppliedAuthoritativeHistory: boolean;
|
hasAppliedAuthoritativeHistory: boolean;
|
||||||
panelToast: ReturnType<typeof useToastHost>;
|
toastApi: ToastApi;
|
||||||
|
toast: ToastState | null;
|
||||||
|
dismiss: () => void;
|
||||||
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
|
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
|
||||||
animatedContentStyle: object[];
|
animatedContentStyle: object[];
|
||||||
handleFilesDropped: (files: ImageAttachment[]) => void;
|
handleFilesDropped: (files: ImageAttachment[]) => void;
|
||||||
@@ -1166,16 +1180,33 @@ function ChatAgentReadyContent({
|
|||||||
handleMessageSent: () => void;
|
handleMessageSent: () => void;
|
||||||
showHistorySyncOverlay: boolean;
|
showHistorySyncOverlay: boolean;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
attentionController: ReturnType<typeof useAgentAttentionClear>;
|
onAttentionInputFocus: () => void;
|
||||||
|
onAttentionPromptSend: () => void;
|
||||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const agentInputDraft = useAgentInputDraft({
|
const rawAgentInputDraft = useAgentInputDraft({
|
||||||
draftKey: buildDraftStoreKey({
|
draftKey: buildDraftStoreKey({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
// Stabilize the agentInputDraft object identity so that memo(AgentComposerSection) can bail out
|
||||||
|
// when only toast state changes (which does not affect any draft field).
|
||||||
|
const { text, setText, attachments, setAttachments, clear, isHydrated, composerState } =
|
||||||
|
rawAgentInputDraft;
|
||||||
|
const agentInputDraft = useMemo(
|
||||||
|
(): AgentInputDraft => ({
|
||||||
|
text,
|
||||||
|
setText,
|
||||||
|
attachments,
|
||||||
|
setAttachments,
|
||||||
|
clear,
|
||||||
|
isHydrated,
|
||||||
|
composerState,
|
||||||
|
}),
|
||||||
|
[text, setText, attachments, setAttachments, clear, isHydrated, composerState],
|
||||||
|
);
|
||||||
const streamSection = (
|
const streamSection = (
|
||||||
<RenderProfile id={`AgentStreamSection:${agentId}`}>
|
<RenderProfile id={`AgentStreamSection:${agentId}`}>
|
||||||
<AgentStreamSection
|
<AgentStreamSection
|
||||||
@@ -1185,7 +1216,7 @@ function ChatAgentReadyContent({
|
|||||||
agent={effectiveAgent}
|
agent={effectiveAgent}
|
||||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||||
toast={panelToast.api}
|
toast={toastApi}
|
||||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||||
/>
|
/>
|
||||||
</RenderProfile>
|
</RenderProfile>
|
||||||
@@ -1201,8 +1232,8 @@ function ChatAgentReadyContent({
|
|||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
isSubmitLoading={false}
|
isSubmitLoading={false}
|
||||||
agentInputDraft={agentInputDraft}
|
agentInputDraft={agentInputDraft}
|
||||||
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
onAttentionInputFocus={onAttentionInputFocus}
|
||||||
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
onAttentionPromptSend={onAttentionPromptSend}
|
||||||
onAddImages={handleAddImagesCallback}
|
onAddImages={handleAddImagesCallback}
|
||||||
onAddFiles={handleAddFilesCallback}
|
onAddFiles={handleAddFilesCallback}
|
||||||
onComposerHeightChange={handleComposerHeightChange}
|
onComposerHeightChange={handleComposerHeightChange}
|
||||||
@@ -1234,11 +1265,7 @@ function ChatAgentReadyContent({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<ToastViewport
|
<ToastViewport toast={toast} onDismiss={dismiss} placement="panel" />
|
||||||
toast={panelToast.toast}
|
|
||||||
onDismiss={panelToast.dismiss}
|
|
||||||
placement="panel"
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
</FileDropZone>
|
</FileDropZone>
|
||||||
|
|
||||||
@@ -1252,7 +1279,7 @@ function ChatAgentReadyContent({
|
|||||||
</View>
|
</View>
|
||||||
</RewindComposerRestoreProvider>
|
</RewindComposerRestoreProvider>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
const AgentStreamSection = memo(function AgentStreamSection({
|
const AgentStreamSection = memo(function AgentStreamSection({
|
||||||
streamViewRef,
|
streamViewRef,
|
||||||
@@ -1320,7 +1347,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function AgentComposerSection({
|
const AgentComposerSection = memo(function AgentComposerSection({
|
||||||
agentId,
|
agentId,
|
||||||
serverId,
|
serverId,
|
||||||
isPaneFocused,
|
isPaneFocused,
|
||||||
@@ -1377,7 +1404,7 @@ function AgentComposerSection({
|
|||||||
onMessageSent={onMessageSent}
|
onMessageSent={onMessageSent}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
function ActiveAgentComposer({
|
function ActiveAgentComposer({
|
||||||
agentId,
|
agentId,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ import {
|
|||||||
FloatingPanelPortalHostNameProvider,
|
FloatingPanelPortalHostNameProvider,
|
||||||
} from "@/components/ui/floating-panel-portal";
|
} from "@/components/ui/floating-panel-portal";
|
||||||
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||||
import { SplitContainer } from "@/components/split-container";
|
import { MountedTabActiveContext, SplitContainer } from "@/components/split-container";
|
||||||
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
|
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
|
||||||
import { WorkspaceGitActions } from "@/git/workspace-actions";
|
import { WorkspaceGitActions } from "@/git/workspace-actions";
|
||||||
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
|
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
|
||||||
@@ -855,13 +855,15 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<RenderProfile id={`MobileMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
|
<RenderProfile id={`MobileMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
|
||||||
<View style={slotStyle} pointerEvents={isVisible ? "auto" : "none"}>
|
<MountedTabActiveContext value={isVisible}>
|
||||||
<WorkspacePaneContent
|
<View style={slotStyle} pointerEvents={isVisible ? "auto" : "none"}>
|
||||||
content={content}
|
<WorkspacePaneContent
|
||||||
isWorkspaceFocused={isWorkspaceFocused}
|
content={content}
|
||||||
isPaneFocused={isPaneFocused}
|
isWorkspaceFocused={isWorkspaceFocused}
|
||||||
/>
|
isPaneFocused={isPaneFocused}
|
||||||
</View>
|
/>
|
||||||
|
</View>
|
||||||
|
</MountedTabActiveContext>
|
||||||
</RenderProfile>
|
</RenderProfile>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user