Prevent Android chats from freezing or going blank (#1989)

* fix(app): stabilize streamed chat rendering

Use deterministic Markdown AST keys and preserve unique row identities when an assistant message resumes after a tool. Keep native gesture and plan-card children correctly keyed to avoid unsupported Fabric reconciliation paths.

* fix(app): stabilize retained native panels

Keep retained workspace roots in a stable native order and make active
panels present in the selection commit. Hidden panels now stop expensive
subscriptions and animations without losing mounted state.

* fix(app): harden retained panel activity
This commit is contained in:
Mohamed Boudra
2026-07-11 15:39:14 +08:00
committed by GitHub
parent 860fcb2e35
commit 28a27b02d3
40 changed files with 1244 additions and 543 deletions
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import {
orderWorkspaceSelectionsForStableRender,
pruneMountedWorkspaceSelections,
shouldKeepWorkspaceDeckEntryMounted,
} from "@/screens/workspace/workspace-deck-retention";
@@ -14,6 +15,17 @@ function mountedWorkspaceIds(selections: ActiveWorkspaceSelection[]): string[] {
}
describe("pruneMountedWorkspaceSelections", () => {
it("retains the deck while an app-wide route temporarily clears the active workspace", () => {
const mountedSelections = [workspace("A"), workspace("B")];
expect(
pruneMountedWorkspaceSelections({
currentSelections: mountedSelections,
activeSelection: null,
}),
).toBe(mountedSelections);
});
it("keeps the active workspace and the two most recent inactive workspaces", () => {
const mountedAfterA = pruneMountedWorkspaceSelections({
currentSelections: [],
@@ -64,6 +76,22 @@ describe("pruneMountedWorkspaceSelections", () => {
});
});
describe("orderWorkspaceSelectionsForStableRender", () => {
it("does not move retained native roots when the active LRU order changes", () => {
const activeA = [workspace("A"), workspace("B")];
const activeB = [workspace("B"), workspace("A")];
expect(mountedWorkspaceIds(orderWorkspaceSelectionsForStableRender(activeA))).toEqual([
"A",
"B",
]);
expect(mountedWorkspaceIds(orderWorkspaceSelectionsForStableRender(activeB))).toEqual([
"A",
"B",
]);
});
});
describe("shouldKeepWorkspaceDeckEntryMounted", () => {
it("keeps the active workspace mounted even when it is missing from hydrated workspaces", () => {
expect(
@@ -43,7 +43,7 @@ export function pruneMountedWorkspaceSelections({
maxMountedWorkspaces = WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
}: PruneMountedWorkspaceSelectionsInput): ActiveWorkspaceSelection[] {
if (!activeSelection) {
return [];
return currentSelections;
}
const maxSelections = Math.max(1, maxMountedWorkspaces);
@@ -74,6 +74,14 @@ export function pruneMountedWorkspaceSelections({
return nextSelections;
}
export function orderWorkspaceSelectionsForStableRender(
selections: ActiveWorkspaceSelection[],
): ActiveWorkspaceSelection[] {
return [...selections].sort((left, right) =>
getWorkspaceSelectionKey(left).localeCompare(getWorkspaceSelectionKey(right)),
);
}
export function shouldKeepWorkspaceDeckEntryMounted({
isActive,
hasHydratedWorkspaces,
@@ -59,7 +59,8 @@ import {
FloatingPanelPortalHostNameProvider,
} from "@/components/ui/floating-panel-portal";
import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { MountedTabActiveContext, SplitContainer } from "@/components/split-container";
import { SplitContainer } from "@/components/split-container";
import { RetainedPanel } from "@/components/retained-panel";
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";
@@ -847,21 +848,15 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
[buildPaneContentModel, paneId, tabDescriptor],
);
const slotStyle = isVisible
? styles.mobileMountedTabSlotVisible
: styles.mobileMountedTabSlotHidden;
return (
<RenderProfile id={`MobileMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
<MountedTabActiveContext value={isVisible}>
<View style={slotStyle} pointerEvents={isVisible ? "auto" : "none"}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View>
</MountedTabActiveContext>
<RetainedPanel active={isVisible} style={styles.mobileMountedTabSlot}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</RetainedPanel>
</RenderProfile>
);
});
@@ -3965,13 +3960,8 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0,
position: "relative",
},
mobileMountedTabSlotVisible: {
mobileMountedTabSlot: {
...StyleSheet.absoluteFillObject,
opacity: 1,
},
mobileMountedTabSlotHidden: {
...StyleSheet.absoluteFillObject,
opacity: 0,
},
contentPlaceholder: {
flex: 1,