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 {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
@@ -63,6 +64,7 @@ import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { normalizeInlinePathTarget } from "@/utils/inline-path";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
getWorkingIndicatorDotStrength,
WORKING_INDICATOR_CYCLE_MS,
@@ -88,7 +90,7 @@ export interface AgentStreamViewProps {
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}
export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
function AgentStreamView(
{
agentId,
@@ -136,6 +138,11 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
workspaceId,
workspaceRoot,
});
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
filePath: string;
}) {
onOpenWorkspaceFile?.(input);
});
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
// tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android";
@@ -164,7 +171,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
if (normalized.file) {
if (onOpenWorkspaceFile) {
onOpenWorkspaceFile({ filePath: normalized.file });
openWorkspaceFile({ filePath: normalized.file });
return;
}
@@ -197,7 +204,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
resolvedServerId,
router,
setExplorerTabForCheckout,
onOpenWorkspaceFile,
openWorkspaceFile,
workspaceId,
],
);
@@ -657,6 +664,9 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
},
);
export const AgentStreamView = memo(AgentStreamViewComponent);
AgentStreamView.displayName = "AgentStreamView";
function WorkingIndicator() {
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 { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useStableEvent } from "@/hooks/use-stable-event";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import {
@@ -103,12 +104,13 @@ function useAgentPanelDescriptor(
function AgentPanel() {
const { serverId, target, isPaneFocused, openFileInWorkspace } = usePaneContext();
invariant(target.kind === "agent", "AgentPanel requires agent target");
const handleOpenWorkspaceFile = useCallback(
(input: { filePath: string }) => {
openFileInWorkspace(input.filePath);
},
[openFileInWorkspace],
);
function openWorkspaceFile(input: { filePath: string }) {
openFileInWorkspace(input.filePath);
}
const handleOpenWorkspaceFile = useStableEvent(openWorkspaceFile);
return (
<AgentPanelContent
serverId={serverId}
@@ -1774,6 +1774,84 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
<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 (
<View style={[styles.container, { backgroundColor: mainBackgroundColor }]}>
{Platform.OS === "web" && activeTabDescriptor ? (
@@ -2025,38 +2103,15 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
onCloseOtherTabs={handleCloseOtherTabsInPane}
onSelectNewTabOption={handleSelectNewTabOption}
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
buildPaneContentModel={({ paneId, tab, isPaneFocused }) =>
buildPaneContentModel({
tab,
paneId,
isPaneFocused,
focusPaneBeforeOpen: true,
})
}
onFocusPane={(paneId) => {
focusWorkspacePane(persistenceKey, paneId);
}}
buildPaneContentModel={buildDesktopPaneContentModel}
onFocusPane={handleFocusPane}
onNewTerminalTab={handleCreateTerminal}
onSplitPane={(input) => {
splitWorkspacePane(persistenceKey, input);
}}
onSplitPaneEmpty={(input) => {
handleCreateDraftSplit(input);
}}
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>
)}
onSplitPane={handleSplitPane}
onSplitPaneEmpty={handleCreateDraftSplit}
onMoveTabToPane={handleMoveTabToPane}
onResizeSplit={handleResizePaneSplit}
onReorderTabsInPane={handleReorderTabsInPane}
renderPaneEmptyState={renderSplitPaneEmptyState}
/>
) : (
content