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,9 +1,10 @@
import React, { createContext, useContext, useMemo, type ReactNode } from "react";
import {
useSidebarWorkspacesList,
type SidebarWorkspaceEntry,
type SidebarWorkspacesListResult,
} from "@/hooks/use-sidebar-workspaces-list";
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
import { useSidebarWorkspaceEntries } from "@/hooks/use-sidebar-workspace-entries";
import { buildStatusGroups, type StatusGroup } from "@/hooks/sidebar-status-view-model";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
@@ -14,6 +15,7 @@ import {
} from "@/utils/sidebar-shortcuts";
interface SidebarModel extends SidebarWorkspacesListResult {
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
groupMode: SidebarGroupMode;
statusGroups: StatusGroup[];
collapsedProjectKeys: ReadonlySet<string>;
@@ -23,7 +25,13 @@ interface SidebarModel extends SidebarWorkspacesListResult {
const SidebarModelContext = createContext<SidebarModel | null>(null);
export function SidebarModelProvider({ children }: { children: ReactNode }) {
export function SidebarModelProvider({
active,
children,
}: {
active?: boolean;
children: ReactNode;
}) {
const list = useSidebarWorkspacesList();
const groupMode = useSidebarViewStore((state) => state.groupMode);
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
@@ -36,14 +44,16 @@ export function SidebarModelProvider({ children }: { children: ReactNode }) {
(state) => state.toggleProjectCollapsed,
);
const isStatusMode = groupMode === "status";
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
placements: list.workspacePlacements,
enabled: isStatusMode,
});
const workspaceEntriesByKey = useSidebarWorkspaceEntries(
list.workspacePlacements,
active !== false || isStatusMode,
);
const statusGroups = useMemo(
() =>
isStatusMode ? buildStatusGroups(statusWorkspacePlacements, list.projectNamesByKey) : [],
[isStatusMode, list.projectNamesByKey, statusWorkspacePlacements],
isStatusMode
? buildStatusGroups(Array.from(workspaceEntriesByKey.values()), list.projectNamesByKey)
: [],
[isStatusMode, list.projectNamesByKey, workspaceEntriesByKey],
);
const shortcutModel = useMemo(() => {
if (isStatusMode) {
@@ -57,13 +67,22 @@ export function SidebarModelProvider({ children }: { children: ReactNode }) {
const value = useMemo(
() => ({
...list,
workspaceEntriesByKey,
groupMode,
statusGroups,
collapsedProjectKeys,
toggleProjectCollapsed,
shortcutModel,
}),
[collapsedProjectKeys, groupMode, list, shortcutModel, statusGroups, toggleProjectCollapsed],
[
collapsedProjectKeys,
groupMode,
list,
shortcutModel,
statusGroups,
toggleProjectCollapsed,
workspaceEntriesByKey,
],
);
return <SidebarModelContext.Provider value={value}>{children}</SidebarModelContext.Provider>;
@@ -4,11 +4,7 @@ import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } fr
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import {
useSidebarWorkspaceEntry,
type SidebarStatusWorkspacePlacement,
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { StyleSheet } from "react-native-unistyles";
@@ -286,13 +282,12 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
showShortcutBadge,
onWorkspacePress,
}: {
workspace: SidebarStatusWorkspacePlacement;
workspace: SidebarWorkspaceEntry;
subtitle: string;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onWorkspacePress?: () => void;
}) {
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const selected =
activeWorkspaceSelection?.serverId === workspace.serverId &&
@@ -304,11 +299,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
if (!workspaceEntry) return null;
return (
<StatusWorkspaceRowWithMenu
workspace={workspaceEntry}
workspace={workspace}
subtitle={subtitle}
selected={selected}
shortcutNumber={shortcutNumber}
@@ -142,7 +142,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
</Text>
{scriptIconKind ? <WorkspaceScriptIcon kind={scriptIconKind} /> : null}
</View>
<View style={styles.workspaceRowRight}>{children}</View>
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
</View>
{subtitle ? (
<Text style={styles.workspaceSubtitle} numberOfLines={1}>
@@ -502,7 +502,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
workspaceRowRight: sidebarWorkspaceRowStyles.rowRight,
shortcutBadgeOverlay: {
position: "absolute",
top: 1,