Stop mobile sidebars getting stuck out of sync (#1953)

* fix(app): keep mobile sidebars synced with store

Gesture-owned skip flags could swallow the next store sync after a no-op or overlapping gesture, leaving React state and Reanimated shared values split. Always reconcile both mobile sidebars from the panel store after gesture animations.

* test(app): keep sidebar sync coverage pure

* fix(app): keep sidebar close transitions idempotent

* fix(app): keep mobile sidebars stable through interruptions

Independent React and worklet state let stale gesture or animation callbacks
overwrite newer panel commands. Use one revisioned target and one transient
motion runtime so outdated callbacks cannot restore an earlier panel.
This commit is contained in:
Mohamed Boudra
2026-07-09 23:08:56 +02:00
committed by GitHub
parent 15d3091104
commit 6af1379357
21 changed files with 1434 additions and 1651 deletions
+29 -27
View File
@@ -26,10 +26,12 @@ import {
migratePanelState,
selectIsAgentListOpen,
selectIsFileExplorerOpen,
setMobilePanelTarget,
selectPanelVisibility,
type DesktopSidebarState,
type ExplorerPanelIntent,
type MobilePanelView,
type MobilePanelSelection,
type PanelLayoutInput,
type PanelVisibilityState,
type SortOption,
@@ -41,6 +43,7 @@ export type {
DesktopSidebarState,
ExplorerPanelIntent,
MobilePanelView,
MobilePanelSelection,
PanelLayoutInput,
PanelVisibilityState,
SortOption,
@@ -61,8 +64,8 @@ export {
};
export interface PanelState {
// Mobile: which panel is currently shown
mobileView: MobilePanelView;
// Mobile: React's durable target plus the generation that owns it.
mobilePanel: MobilePanelSelection;
// Desktop: independent sidebar toggles
desktop: DesktopSidebarState;
@@ -114,11 +117,19 @@ export interface PanelState {
const DEFAULT_DESKTOP_OPEN = isWeb;
function setMobilePanelTargetPatch(
state: PanelState,
target: MobilePanelView,
): PanelState | Pick<PanelState, "mobilePanel"> {
const mobilePanel = setMobilePanelTarget(state.mobilePanel, target);
return mobilePanel === state.mobilePanel ? state : { mobilePanel };
}
export const usePanelStore = create<PanelState>()(
persist(
(set) => ({
// Mobile always starts at agent view
mobileView: "agent",
mobilePanel: { target: "agent", revision: 0 },
// Desktop defaults based on platform
desktop: {
@@ -144,26 +155,17 @@ export const usePanelStore = create<PanelState>()(
desktop: { ...state.desktop, focusModeEnabled: !state.desktop.focusModeEnabled },
})),
showMobileAgent: () =>
set((state) => {
if (state.mobileView === "agent") {
return state;
}
return { mobileView: "agent" as const };
}),
showMobileAgent: () => set((state) => setMobilePanelTargetPatch(state, "agent")),
showMobileAgentList: () =>
set((state) => {
if (state.mobileView === "agent-list") {
return state;
}
return { mobileView: "agent-list" as const };
}),
showMobileAgentList: () => set((state) => setMobilePanelTargetPatch(state, "agent-list")),
toggleMobileAgentList: () =>
set((state) => ({
mobileView: state.mobileView === "agent-list" ? "agent" : "agent-list",
})),
set((state) =>
setMobilePanelTargetPatch(
state,
state.mobilePanel.target === "agent-list" ? "agent" : "agent-list",
),
),
openDesktopAgentList: () =>
set((state) => {
@@ -197,9 +199,7 @@ export const usePanelStore = create<PanelState>()(
openAgentListForLayout: ({ isCompact }) =>
set((state) => {
if (isCompact) {
return state.mobileView === "agent-list"
? state
: { mobileView: "agent-list" as const };
return setMobilePanelTargetPatch(state, "agent-list");
}
return state.desktop.agentListOpen
? state
@@ -209,7 +209,7 @@ export const usePanelStore = create<PanelState>()(
closeAgentListForLayout: ({ isCompact }) =>
set((state) => {
if (isCompact) {
return state.mobileView === "agent" ? state : { mobileView: "agent" as const };
return setMobilePanelTargetPatch(state, "agent");
}
return state.desktop.agentListOpen
? { desktop: { ...state.desktop, agentListOpen: false } }
@@ -219,7 +219,10 @@ export const usePanelStore = create<PanelState>()(
toggleAgentListForLayout: ({ isCompact }) =>
set((state) => {
if (isCompact) {
return { mobileView: state.mobileView === "agent-list" ? "agent" : "agent-list" };
return setMobilePanelTargetPatch(
state,
state.mobilePanel.target === "agent-list" ? "agent" : "agent-list",
);
}
return {
desktop: { ...state.desktop, agentListOpen: !state.desktop.agentListOpen },
@@ -295,7 +298,6 @@ export const usePanelStore = create<PanelState>()(
migrate: (persistedState, version) =>
migratePanelState(persistedState, version, { isWeb }) as unknown as PanelState,
partialize: (state) => ({
mobileView: state.mobileView,
desktop: state.desktop,
explorerTab: state.explorerTab,
explorerTabByCheckout: state.explorerTabByCheckout,
@@ -315,7 +317,7 @@ export const usePanelStore = create<PanelState>()(
/**
* Hook that provides platform-aware panel state.
*
* On mobile, uses the state machine (mobileView).
* On mobile, uses the revisioned mobile panel target.
* On desktop, uses independent booleans (desktop.agentListOpen, desktop.fileExplorerOpen).
*
* @param isMobile - Whether the current breakpoint is mobile
@@ -10,13 +10,14 @@ import {
migratePanelState,
selectIsAgentListOpen,
selectIsFileExplorerOpen,
setMobilePanelTarget,
selectPanelVisibility,
type PanelCoreState,
} from "./state";
function makePanelState(overrides: Partial<PanelCoreState> = {}): PanelCoreState {
return {
mobileView: "agent",
mobilePanel: { target: "agent", revision: 0 },
desktop: {
agentListOpen: false,
fileExplorerOpen: false,
@@ -117,12 +118,33 @@ describe("panel-store migration", () => {
expect(state.diffCollapsedFoldersByWorkspace).toEqual({ ws: ["src/app"] });
});
it("drops persisted compact panel state so cold starts return to content", () => {
const state = migratePanelState(
{ mobileView: "agent-list", mobilePanel: { target: "file-explorer", revision: 42 } },
11,
{ isWeb: false },
);
expect(state.mobileView).toBeUndefined();
expect(state.mobilePanel).toBeUndefined();
});
});
describe("panel-store visibility selectors", () => {
it("uses mobileView for compact layout visibility", () => {
it("increments the mobile panel revision only when the target changes", () => {
const initial = { target: "agent" as const, revision: 4 };
expect(setMobilePanelTarget(initial, "agent")).toBe(initial);
expect(setMobilePanelTarget(initial, "agent-list")).toEqual({
target: "agent-list",
revision: 5,
});
});
it("uses the mobile panel target for compact layout visibility", () => {
const state = makePanelState({
mobileView: "file-explorer",
mobilePanel: { target: "file-explorer", revision: 1 },
desktop: { agentListOpen: true, fileExplorerOpen: false, focusModeEnabled: false },
});
@@ -136,7 +158,7 @@ describe("panel-store visibility selectors", () => {
it("uses desktop flags for expanded layout visibility", () => {
const state = makePanelState({
mobileView: "file-explorer",
mobilePanel: { target: "file-explorer", revision: 1 },
desktop: { agentListOpen: true, fileExplorerOpen: false, focusModeEnabled: false },
});
@@ -160,7 +182,7 @@ describe("panel-store checkout-intent file explorer actions", () => {
const patch = buildOpenFileExplorerPatch(state, { isCompact: true, checkout });
expect(patch.mobileView).toBe("file-explorer");
expect(patch.mobilePanel).toEqual({ target: "file-explorer", revision: 1 });
expect(patch.desktop).toBeUndefined();
expect(patch.explorerTab).toBe("files");
});
@@ -175,7 +197,7 @@ describe("panel-store checkout-intent file explorer actions", () => {
const patch = buildOpenFileExplorerPatch(state, { isCompact: false, checkout });
expect(patch.mobileView).toBeUndefined();
expect(patch.mobilePanel).toBeUndefined();
expect(patch.desktop?.fileExplorerOpen).toBe(true);
expect(patch.explorerTab).toBe("files");
});
+28 -7
View File
@@ -8,6 +8,11 @@ import { type ExplorerCheckoutContext } from "../explorer-checkout-context";
export type MobilePanelView = "agent" | "agent-list" | "file-explorer";
export interface MobilePanelSelection {
target: MobilePanelView;
revision: number;
}
export interface DesktopSidebarState {
agentListOpen: boolean;
fileExplorerOpen: boolean;
@@ -43,7 +48,7 @@ export interface ExplorerPanelIntent extends PanelLayoutInput {
}
export interface PanelCoreState {
mobileView: MobilePanelView;
mobilePanel: MobilePanelSelection;
desktop: DesktopSidebarState;
explorerTab: ExplorerTab;
explorerTabByCheckout: Record<string, ExplorerTab>;
@@ -74,8 +79,8 @@ export function selectPanelVisibility(
): PanelVisibilityState {
if (input.isCompact) {
return {
isAgentListOpen: state.mobileView === "agent-list",
isFileExplorerOpen: state.mobileView === "file-explorer",
isAgentListOpen: state.mobilePanel.target === "agent-list",
isFileExplorerOpen: state.mobilePanel.target === "file-explorer",
};
}
return {
@@ -92,6 +97,16 @@ export function selectIsFileExplorerOpen(state: PanelCoreState, input: PanelLayo
return selectPanelVisibility(state, input).isFileExplorerOpen;
}
export function setMobilePanelTarget(
selection: MobilePanelSelection,
target: MobilePanelView,
): MobilePanelSelection {
if (selection.target === target) {
return selection;
}
return { target, revision: selection.revision + 1 };
}
function resolveExplorerTabFromCheckout(
state: PanelCoreState,
checkout: ExplorerCheckoutContext,
@@ -105,7 +120,7 @@ function resolveExplorerTabFromCheckout(
}
export interface OpenFileExplorerPatch {
mobileView?: MobilePanelView;
mobilePanel?: MobilePanelSelection;
desktop?: DesktopSidebarState;
explorerTab: ExplorerTab;
}
@@ -117,7 +132,7 @@ export function buildOpenFileExplorerPatch(
const resolvedTab = resolveExplorerTabFromCheckout(state, input.checkout);
if (input.isCompact) {
return {
mobileView: "file-explorer",
mobilePanel: setMobilePanelTarget(state.mobilePanel, "file-explorer"),
explorerTab: resolvedTab,
};
}
@@ -129,7 +144,7 @@ export function buildOpenFileExplorerPatch(
export type ToggleFileExplorerPatch =
| OpenFileExplorerPatch
| { mobileView: MobilePanelView }
| { mobilePanel: MobilePanelSelection }
| { desktop: DesktopSidebarState };
export function buildToggleFileExplorerPatch(
@@ -141,7 +156,7 @@ export function buildToggleFileExplorerPatch(
return buildOpenFileExplorerPatch(state, input);
}
if (input.isCompact) {
return { mobileView: "agent" };
return { mobilePanel: setMobilePanelTarget(state.mobilePanel, "agent") };
}
return { desktop: { ...state.desktop, fileExplorerOpen: false } };
}
@@ -255,6 +270,12 @@ export function migratePanelState(
if (typeof state.explorerShowHiddenFiles !== "boolean") {
state.explorerShowHiddenFiles = true;
}
if (version < 12) {
// Compact panel position is transient UI state. Cold starts always begin
// at content, regardless of what an older version persisted.
delete state.mobileView;
delete state.mobilePanel;
}
return state;
}