mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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 {
|
||||
const auxiliaryTurnFooter = resolveAuxiliaryTurnFooter(input);
|
||||
const historyBoundaryIndex = input.strategy.getHistoryLiveBoundaryIndex(input.history);
|
||||
@@ -215,17 +220,46 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
const liveHeadBoundaryItem =
|
||||
liveHeadBoundaryIndex === null ? null : (input.liveHead[liveHeadBoundaryIndex] ?? null);
|
||||
const frameOrder = input.strategy.getFrameChildOrder();
|
||||
const history = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
agentStatus: input.agentStatus,
|
||||
items: input.history,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
frameOrder,
|
||||
boundaryIndex: historyBoundaryIndex,
|
||||
boundaryAboveItem: null,
|
||||
boundaryBelowItem: liveHeadBoundaryItem,
|
||||
});
|
||||
|
||||
let history: StreamLayoutItem[];
|
||||
if (input.history.length > 0) {
|
||||
// The cache key encodes every input that can change history layout. liveHeadBoundaryItem.id
|
||||
// and .kind are stable across text-only flushes (text growth doesn't change what kind of
|
||||
// item borders history), so cached layout stays valid between flushes.
|
||||
const historyCacheKey = [
|
||||
input.agentStatus,
|
||||
frameOrder,
|
||||
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({
|
||||
strategy: input.strategy,
|
||||
agentStatus: input.agentStatus,
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
@@ -80,6 +81,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||
import { MountedTabActiveContext } from "@/components/split-container";
|
||||
|
||||
function renderLiveAuxiliaryNode(input: {
|
||||
pendingPermissions: ReactNode;
|
||||
@@ -364,15 +366,29 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
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(() => {
|
||||
return buildAgentStreamRenderModel({
|
||||
agentStatus: agent.status,
|
||||
tail: streamItems,
|
||||
head: streamHead ?? EMPTY_STREAM_HEAD,
|
||||
tail: effectiveStreamItems,
|
||||
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
|
||||
platform: isWeb ? "web" : "native",
|
||||
isMobileBreakpoint: isMobile,
|
||||
});
|
||||
}, [agent.status, isMobile, streamHead, streamItems]);
|
||||
}, [agent.status, isMobile, effectiveStreamHead, effectiveStreamItems]);
|
||||
const streamLayout = useMemo(
|
||||
() =>
|
||||
layoutStream({
|
||||
@@ -684,14 +700,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
(item) => renderHistoryRow(item),
|
||||
[renderHistoryRow],
|
||||
);
|
||||
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
|
||||
(item) =>
|
||||
// useStableEvent keeps the function reference stable across flushes.
|
||||
// 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({
|
||||
item,
|
||||
layoutItemById: layoutLiveHeadItemById,
|
||||
renderStreamItem,
|
||||
}),
|
||||
[layoutLiveHeadItemById, renderStreamItem],
|
||||
);
|
||||
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => {
|
||||
return renderLiveAuxiliaryNode({
|
||||
|
||||
@@ -41,7 +41,10 @@ import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
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 { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
||||
@@ -615,12 +618,12 @@ function MobileSidebar({
|
||||
windowWidth,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
settledGeneration,
|
||||
isGesturing,
|
||||
mobilePanelState,
|
||||
gestureAnimatingRef,
|
||||
closeGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
const settledGeneration = useSidebarSettledGeneration();
|
||||
const closeTouchStartX = useSharedValue(0);
|
||||
const closeTouchStartY = useSharedValue(0);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Fragment,
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -73,6 +74,10 @@ import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||
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 {
|
||||
layout: WorkspaceLayout;
|
||||
workspaceKey: string;
|
||||
@@ -219,14 +224,16 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
||||
|
||||
return (
|
||||
<RenderProfile id={`DesktopMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
|
||||
<View style={wrapperStyle}>
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onFocusPane={handleFocusPane}
|
||||
/>
|
||||
</View>
|
||||
<MountedTabActiveContext value={isVisible}>
|
||||
<View style={wrapperStyle}>
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onFocusPane={handleFocusPane}
|
||||
/>
|
||||
</View>
|
||||
</MountedTabActiveContext>
|
||||
</RenderProfile>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -20,14 +20,20 @@ export function WorkspaceShortcutTargetsSubscriber({
|
||||
serverId: string | null;
|
||||
}) {
|
||||
const { projects } = useSidebarWorkspacesList({ serverId, enabled });
|
||||
const statusWorkspaces = useStatusModeWorkspaceEntries({
|
||||
serverId: enabled ? serverId : null,
|
||||
projects,
|
||||
});
|
||||
const projectNamesByKey = useProjectNamesMap(enabled ? serverId : null);
|
||||
// groupMode must be resolved before gating the status-mode subscriptions below.
|
||||
const groupMode = useSidebarViewStore((state) =>
|
||||
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(
|
||||
(state) => state.collapsedProjectKeys,
|
||||
);
|
||||
|
||||
@@ -50,7 +50,6 @@ interface SidebarAnimationContextValue {
|
||||
animateToClose: () => void;
|
||||
startMobilePanelTransition: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
|
||||
settleMobilePanel: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
|
||||
settledGeneration: number;
|
||||
isGesturing: SharedValue<boolean>;
|
||||
mobileVisualPanel: SharedValue<number>;
|
||||
mobilePanelState: SharedValue<number>;
|
||||
@@ -61,6 +60,10 @@ interface SidebarAnimationContextValue {
|
||||
|
||||
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 {
|
||||
if (mobileView === "agent-list") {
|
||||
return MOBILE_VISUAL_PANEL_AGENT_LIST;
|
||||
@@ -357,7 +360,6 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
||||
animateToClose,
|
||||
startMobilePanelTransition,
|
||||
settleMobilePanel,
|
||||
settledGeneration,
|
||||
isGesturing,
|
||||
mobileVisualPanel,
|
||||
mobilePanelState,
|
||||
@@ -373,7 +375,6 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
||||
animateToClose,
|
||||
startMobilePanelTransition,
|
||||
settleMobilePanel,
|
||||
settledGeneration,
|
||||
isGesturing,
|
||||
mobileVisualPanel,
|
||||
mobilePanelState,
|
||||
@@ -384,7 +385,11 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function useSidebarSettledGeneration() {
|
||||
return useContext(SidebarSettledGenerationContext);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
type ReactNode,
|
||||
@@ -57,22 +58,27 @@ export function useVoice() {
|
||||
export function useVoiceOptional(): VoiceContextValue | null {
|
||||
const runtime = useContext(VoiceRuntimeContext);
|
||||
const snapshot = useSyncExternalStore(
|
||||
runtime ? runtime.subscribe.bind(runtime) : noopSubscribe,
|
||||
runtime ? runtime.getSnapshot.bind(runtime) : getEmptySnapshot,
|
||||
runtime ? runtime.getSnapshot.bind(runtime) : getEmptySnapshot,
|
||||
runtime ? runtime.subscribe : noopSubscribe,
|
||||
runtime ? runtime.getSnapshot : getEmptySnapshot,
|
||||
runtime ? runtime.getSnapshot : getEmptySnapshot,
|
||||
);
|
||||
|
||||
if (!runtime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
startVoice: runtime.startVoice.bind(runtime),
|
||||
stopVoice: runtime.stopVoice.bind(runtime),
|
||||
isVoiceModeForAgent: runtime.isVoiceModeForAgent.bind(runtime),
|
||||
toggleMute: runtime.toggleMute.bind(runtime),
|
||||
};
|
||||
// Methods on the runtime object literal close over factory-local state; they
|
||||
// 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 useMemo(() => {
|
||||
if (!runtime) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...snapshot,
|
||||
startVoice: runtime.startVoice,
|
||||
stopVoice: runtime.stopVoice,
|
||||
isVoiceModeForAgent: runtime.isVoiceModeForAgent,
|
||||
toggleMute: runtime.toggleMute,
|
||||
};
|
||||
}, [snapshot, runtime]);
|
||||
}
|
||||
|
||||
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 { useSessionStore } from "@/stores/session-store";
|
||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||
@@ -44,6 +45,13 @@ export function useAggregatedAgents(options?: {
|
||||
runtime.refreshAllAgentDirectories();
|
||||
}, [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(() => {
|
||||
// runtimeVersion is referenced so the memo recomputes when runtime state changes.
|
||||
void runtimeVersion;
|
||||
@@ -79,7 +87,11 @@ export function useAggregatedAgents(options?: {
|
||||
createdAt: agent.createdAt,
|
||||
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;
|
||||
});
|
||||
|
||||
// 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
|
||||
const hasAnyData = allAgents.length > 0;
|
||||
const hasAnyData = stableAgents.length > 0;
|
||||
|
||||
// Align list loading with the runtime directory-sync machine.
|
||||
const isLoading = daemons.some((daemon) => {
|
||||
@@ -111,7 +140,7 @@ export function useAggregatedAgents(options?: {
|
||||
const isRevalidating = isLoading && hasAnyData;
|
||||
|
||||
return {
|
||||
agents: allAgents,
|
||||
agents: stableAgents,
|
||||
isLoading,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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 { 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 { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
import { selectPrHintFromStatus } from "@/git/use-pr-status-query";
|
||||
@@ -147,22 +149,31 @@ export function useSidebarWorkspaceEntry(
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
): SidebarWorkspaceEntry | null {
|
||||
const pendingCreateAttempts = useCreateFlowStore((state) => state.pendingByDraftId);
|
||||
const agents = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId]?.agents : undefined,
|
||||
// Deep-compare so that adding/removing unrelated pending creates doesn't re-render this row.
|
||||
const pendingCreateAttempts = useStoreWithEqualityFn(
|
||||
useCreateFlowStore,
|
||||
(state) => state.pendingByDraftId,
|
||||
workspaceEqualityFns.deep,
|
||||
);
|
||||
const projectWorkspaceEntry = useCallback(
|
||||
(workspace: WorkspaceDescriptor): SidebarWorkspaceEntry =>
|
||||
createSidebarWorkspaceEntry({
|
||||
|
||||
// Single subscription: reads workspace + agents together, computes the full entry, and
|
||||
// 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 ?? "",
|
||||
workspace,
|
||||
pendingCreateAttempts,
|
||||
agents,
|
||||
}),
|
||||
[agents, pendingCreateAttempts, serverId],
|
||||
});
|
||||
},
|
||||
equal,
|
||||
);
|
||||
|
||||
return useWorkspaceFields(serverId, workspaceId, projectWorkspaceEntry);
|
||||
}
|
||||
|
||||
const EMPTY_ORDER: string[] = [];
|
||||
|
||||
@@ -22,7 +22,12 @@ import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
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 {
|
||||
useWorkspaceAttachments,
|
||||
@@ -682,7 +687,7 @@ function ChatAgentContent({
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const panelToast = useToastHost();
|
||||
const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost();
|
||||
const { isArchivingAgent } = useArchiveAgent();
|
||||
const streamViewRef = useRef<AgentStreamViewHandle>(null);
|
||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||
@@ -737,10 +742,10 @@ function ChatAgentContent({
|
||||
addFilesRef.current?.(uploaded);
|
||||
} catch (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(
|
||||
@@ -841,7 +846,7 @@ function ChatAgentContent({
|
||||
if (connectionStatus === "online") {
|
||||
if (reconnectToastArmedRef.current) {
|
||||
reconnectToastArmedRef.current = false;
|
||||
panelToast.dismiss();
|
||||
dismissToast();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -850,12 +855,12 @@ function ChatAgentContent({
|
||||
}
|
||||
if (!reconnectToastArmedRef.current) {
|
||||
reconnectToastArmedRef.current = true;
|
||||
panelToast.api.show(t("agentPanel.states.reconnecting"), {
|
||||
toastApi.show(t("agentPanel.states.reconnecting"), {
|
||||
durationMs: null,
|
||||
testID: "agent-reconnecting-toast",
|
||||
});
|
||||
}
|
||||
}, [connectionStatus, panelToast, t]);
|
||||
}, [connectionStatus, dismissToast, toastApi, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPaneFocused || !agentId || !isConnected || !hasSession) {
|
||||
@@ -1089,7 +1094,9 @@ function ChatAgentContent({
|
||||
effectiveAgent={effectiveAgent}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||
panelToast={panelToast}
|
||||
toastApi={toastApi}
|
||||
toast={toastState}
|
||||
dismiss={dismissToast}
|
||||
streamViewRef={streamViewRef}
|
||||
animatedContentStyle={animatedContentStyle}
|
||||
handleFilesDropped={handleFilesDropped}
|
||||
@@ -1100,7 +1107,8 @@ function ChatAgentContent({
|
||||
handleMessageSent={handleMessageSent}
|
||||
showHistorySyncOverlay={showHistorySyncOverlay}
|
||||
cwd={agentCwd}
|
||||
attentionController={attentionController}
|
||||
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
);
|
||||
@@ -1124,7 +1132,8 @@ function isImagePath(path: string): boolean {
|
||||
]);
|
||||
return imageExts.has(ext);
|
||||
}
|
||||
function ChatAgentReadyContent({
|
||||
|
||||
const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
serverId,
|
||||
agentId,
|
||||
isPaneFocused,
|
||||
@@ -1133,7 +1142,9 @@ function ChatAgentReadyContent({
|
||||
effectiveAgent,
|
||||
routeBottomAnchorRequest,
|
||||
hasAppliedAuthoritativeHistory,
|
||||
panelToast,
|
||||
toastApi,
|
||||
toast,
|
||||
dismiss,
|
||||
streamViewRef,
|
||||
animatedContentStyle,
|
||||
handleFilesDropped,
|
||||
@@ -1144,7 +1155,8 @@ function ChatAgentReadyContent({
|
||||
handleMessageSent,
|
||||
showHistorySyncOverlay,
|
||||
cwd,
|
||||
attentionController,
|
||||
onAttentionInputFocus,
|
||||
onAttentionPromptSend,
|
||||
onOpenWorkspaceFile,
|
||||
}: {
|
||||
serverId: string;
|
||||
@@ -1155,7 +1167,9 @@ function ChatAgentReadyContent({
|
||||
effectiveAgent: AgentScreenAgent;
|
||||
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
||||
hasAppliedAuthoritativeHistory: boolean;
|
||||
panelToast: ReturnType<typeof useToastHost>;
|
||||
toastApi: ToastApi;
|
||||
toast: ToastState | null;
|
||||
dismiss: () => void;
|
||||
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
|
||||
animatedContentStyle: object[];
|
||||
handleFilesDropped: (files: ImageAttachment[]) => void;
|
||||
@@ -1166,16 +1180,33 @@ function ChatAgentReadyContent({
|
||||
handleMessageSent: () => void;
|
||||
showHistorySyncOverlay: boolean;
|
||||
cwd: string;
|
||||
attentionController: ReturnType<typeof useAgentAttentionClear>;
|
||||
onAttentionInputFocus: () => void;
|
||||
onAttentionPromptSend: () => void;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const agentInputDraft = useAgentInputDraft({
|
||||
const rawAgentInputDraft = useAgentInputDraft({
|
||||
draftKey: buildDraftStoreKey({
|
||||
serverId,
|
||||
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 = (
|
||||
<RenderProfile id={`AgentStreamSection:${agentId}`}>
|
||||
<AgentStreamSection
|
||||
@@ -1185,7 +1216,7 @@ function ChatAgentReadyContent({
|
||||
agent={effectiveAgent}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||
toast={panelToast.api}
|
||||
toast={toastApi}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
</RenderProfile>
|
||||
@@ -1201,8 +1232,8 @@ function ChatAgentReadyContent({
|
||||
cwd={cwd}
|
||||
isSubmitLoading={false}
|
||||
agentInputDraft={agentInputDraft}
|
||||
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
||||
onAttentionInputFocus={onAttentionInputFocus}
|
||||
onAttentionPromptSend={onAttentionPromptSend}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
onAddFiles={handleAddFilesCallback}
|
||||
onComposerHeightChange={handleComposerHeightChange}
|
||||
@@ -1234,11 +1265,7 @@ function ChatAgentReadyContent({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<ToastViewport
|
||||
toast={panelToast.toast}
|
||||
onDismiss={panelToast.dismiss}
|
||||
placement="panel"
|
||||
/>
|
||||
<ToastViewport toast={toast} onDismiss={dismiss} placement="panel" />
|
||||
</View>
|
||||
</FileDropZone>
|
||||
|
||||
@@ -1252,7 +1279,7 @@ function ChatAgentReadyContent({
|
||||
</View>
|
||||
</RewindComposerRestoreProvider>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const AgentStreamSection = memo(function AgentStreamSection({
|
||||
streamViewRef,
|
||||
@@ -1320,7 +1347,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
);
|
||||
});
|
||||
|
||||
function AgentComposerSection({
|
||||
const AgentComposerSection = memo(function AgentComposerSection({
|
||||
agentId,
|
||||
serverId,
|
||||
isPaneFocused,
|
||||
@@ -1377,7 +1404,7 @@ function AgentComposerSection({
|
||||
onMessageSent={onMessageSent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function ActiveAgentComposer({
|
||||
agentId,
|
||||
|
||||
@@ -60,7 +60,7 @@ import {
|
||||
FloatingPanelPortalHostNameProvider,
|
||||
} from "@/components/ui/floating-panel-portal";
|
||||
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 { WorkspaceGitActions } from "@/git/workspace-actions";
|
||||
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
|
||||
@@ -855,13 +855,15 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
|
||||
|
||||
return (
|
||||
<RenderProfile id={`MobileMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
|
||||
<View style={slotStyle} pointerEvents={isVisible ? "auto" : "none"}>
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</View>
|
||||
<MountedTabActiveContext value={isVisible}>
|
||||
<View style={slotStyle} pointerEvents={isVisible ? "auto" : "none"}>
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</View>
|
||||
</MountedTabActiveContext>
|
||||
</RenderProfile>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user