Reduce workspace render churn

This commit is contained in:
Mohamed Boudra
2026-06-12 21:53:22 +07:00
parent 889d058658
commit c4d544baf6
11 changed files with 252 additions and 99 deletions
+45 -11
View File
@@ -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,
+25 -6
View File
@@ -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({
+5 -2
View File
@@ -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);
}
+20 -14
View File
@@ -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[] = [];
+53 -26
View File
@@ -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>
); );
}); });