Stabilize workspace stream render boundaries

This commit is contained in:
Mohamed Boudra
2026-03-23 10:45:48 +07:00
parent 7f9dafab68
commit 87145f1e6a
4 changed files with 117 additions and 40 deletions
@@ -1,5 +1,6 @@
import { import {
forwardRef, forwardRef,
memo,
useCallback, useCallback,
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
@@ -63,6 +64,7 @@ import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { getMarkdownListMarker } from "@/utils/markdown-list"; import { getMarkdownListMarker } from "@/utils/markdown-list";
import { normalizeInlinePathTarget } from "@/utils/inline-path"; import { normalizeInlinePathTarget } from "@/utils/inline-path";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import { import {
getWorkingIndicatorDotStrength, getWorkingIndicatorDotStrength,
WORKING_INDICATOR_CYCLE_MS, WORKING_INDICATOR_CYCLE_MS,
@@ -88,7 +90,7 @@ export interface AgentStreamViewProps {
onOpenWorkspaceFile?: (input: { filePath: string }) => void; onOpenWorkspaceFile?: (input: { filePath: string }) => void;
} }
export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>( const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
function AgentStreamView( function AgentStreamView(
{ {
agentId, agentId,
@@ -136,6 +138,11 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
workspaceId, workspaceId,
workspaceRoot, workspaceRoot,
}); });
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
filePath: string;
}) {
onOpenWorkspaceFile?.(input);
});
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes // Keep entry/exit animations off on Android due to RN dispatchDraw crashes
// tracked in react-native-reanimated#8422. // tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android"; const shouldDisableEntryExitAnimations = Platform.OS === "android";
@@ -164,7 +171,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
if (normalized.file) { if (normalized.file) {
if (onOpenWorkspaceFile) { if (onOpenWorkspaceFile) {
onOpenWorkspaceFile({ filePath: normalized.file }); openWorkspaceFile({ filePath: normalized.file });
return; return;
} }
@@ -197,7 +204,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
resolvedServerId, resolvedServerId,
router, router,
setExplorerTabForCheckout, setExplorerTabForCheckout,
onOpenWorkspaceFile, openWorkspaceFile,
workspaceId, workspaceId,
], ],
); );
@@ -657,6 +664,9 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
}, },
); );
export const AgentStreamView = memo(AgentStreamViewComponent);
AgentStreamView.displayName = "AgentStreamView";
function WorkingIndicator() { function WorkingIndicator() {
const progress = useSharedValue(0); const progress = useSharedValue(0);
@@ -0,0 +1,10 @@
import { useCallback, useRef } from "react";
export function useStableEvent<TArgs extends unknown[], TResult>(
handler: (...args: TArgs) => TResult,
): (...args: TArgs) => TResult {
const handlerRef = useRef(handler);
handlerRef.current = handler;
return useCallback((...args: TArgs) => handlerRef.current(...args), []);
}
+8 -6
View File
@@ -22,6 +22,7 @@ import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast"; import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useStableEvent } from "@/hooks/use-stable-event";
import { usePaneContext } from "@/panels/pane-context"; import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { import {
@@ -103,12 +104,13 @@ function useAgentPanelDescriptor(
function AgentPanel() { function AgentPanel() {
const { serverId, target, isPaneFocused, openFileInWorkspace } = usePaneContext(); const { serverId, target, isPaneFocused, openFileInWorkspace } = usePaneContext();
invariant(target.kind === "agent", "AgentPanel requires agent target"); invariant(target.kind === "agent", "AgentPanel requires agent target");
const handleOpenWorkspaceFile = useCallback(
(input: { filePath: string }) => { function openWorkspaceFile(input: { filePath: string }) {
openFileInWorkspace(input.filePath); openFileInWorkspace(input.filePath);
}, }
[openFileInWorkspace],
); const handleOpenWorkspaceFile = useStableEvent(openWorkspaceFile);
return ( return (
<AgentPanelContent <AgentPanelContent
serverId={serverId} serverId={serverId}
@@ -1774,6 +1774,84 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
<WorkspacePaneContent content={activePaneContent!} /> <WorkspacePaneContent content={activePaneContent!} />
); );
const buildDesktopPaneContentModel = useCallback(
function buildDesktopPaneContentModel(input: {
paneId: string;
tab: WorkspaceTabDescriptor;
isPaneFocused: boolean;
}) {
return buildPaneContentModel({
tab: input.tab,
paneId: input.paneId,
isPaneFocused: input.isPaneFocused,
focusPaneBeforeOpen: true,
});
},
[buildPaneContentModel],
);
const handleFocusPane = useCallback(
function handleFocusPane(paneId: string) {
if (!persistenceKey) {
return;
}
focusWorkspacePane(persistenceKey, paneId);
},
[focusWorkspacePane, persistenceKey],
);
const handleSplitPane = useCallback(
function handleSplitPane(input: {
tabId: string;
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
}) {
if (!persistenceKey) {
return;
}
splitWorkspacePane(persistenceKey, input);
},
[persistenceKey, splitWorkspacePane],
);
const handleMoveTabToPane = useCallback(
function handleMoveTabToPane(tabId: string, toPaneId: string) {
if (!persistenceKey) {
return;
}
moveWorkspaceTabToPane(persistenceKey, tabId, toPaneId);
},
[moveWorkspaceTabToPane, persistenceKey],
);
const handleResizePaneSplit = useCallback(
function handleResizePaneSplit(groupId: string, sizes: number[]) {
if (!persistenceKey) {
return;
}
resizeWorkspaceSplit(persistenceKey, groupId, sizes);
},
[persistenceKey, resizeWorkspaceSplit],
);
const handleReorderTabsInPane = useCallback(
function handleReorderTabsInPane(paneId: string, tabIds: string[]) {
if (!persistenceKey) {
return;
}
reorderWorkspaceTabsInPane(persistenceKey, paneId, tabIds);
},
[persistenceKey, reorderWorkspaceTabsInPane],
);
const renderSplitPaneEmptyState = useCallback(function renderSplitPaneEmptyState() {
return (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>No tabs in this pane.</Text>
</View>
);
}, []);
return ( return (
<View style={[styles.container, { backgroundColor: mainBackgroundColor }]}> <View style={[styles.container, { backgroundColor: mainBackgroundColor }]}>
{Platform.OS === "web" && activeTabDescriptor ? ( {Platform.OS === "web" && activeTabDescriptor ? (
@@ -2025,38 +2103,15 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
onCloseOtherTabs={handleCloseOtherTabsInPane} onCloseOtherTabs={handleCloseOtherTabsInPane}
onSelectNewTabOption={handleSelectNewTabOption} onSelectNewTabOption={handleSelectNewTabOption}
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID} newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
buildPaneContentModel={({ paneId, tab, isPaneFocused }) => buildPaneContentModel={buildDesktopPaneContentModel}
buildPaneContentModel({ onFocusPane={handleFocusPane}
tab,
paneId,
isPaneFocused,
focusPaneBeforeOpen: true,
})
}
onFocusPane={(paneId) => {
focusWorkspacePane(persistenceKey, paneId);
}}
onNewTerminalTab={handleCreateTerminal} onNewTerminalTab={handleCreateTerminal}
onSplitPane={(input) => { onSplitPane={handleSplitPane}
splitWorkspacePane(persistenceKey, input); onSplitPaneEmpty={handleCreateDraftSplit}
}} onMoveTabToPane={handleMoveTabToPane}
onSplitPaneEmpty={(input) => { onResizeSplit={handleResizePaneSplit}
handleCreateDraftSplit(input); onReorderTabsInPane={handleReorderTabsInPane}
}} renderPaneEmptyState={renderSplitPaneEmptyState}
onMoveTabToPane={(tabId, toPaneId) => {
moveWorkspaceTabToPane(persistenceKey, tabId, toPaneId);
}}
onResizeSplit={(groupId, sizes) => {
resizeWorkspaceSplit(persistenceKey, groupId, sizes);
}}
onReorderTabsInPane={(paneId, tabIds) => {
reorderWorkspaceTabsInPane(persistenceKey, paneId, tabIds);
}}
renderPaneEmptyState={() => (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>No tabs in this pane.</Text>
</View>
)}
/> />
) : ( ) : (
content content