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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user