refactor(app): unified panel state machine for mobile sidebars

Replace separate sidebar stores with a unified panel store that uses a
discriminated union state machine on mobile. This makes it impossible
for both the agent list and file explorer sidebars to be open at the
same time on mobile.

Mobile state machine (mobileView):
- 'agent': Main agent view (no overlay panel)
- 'agent-list': Agent list sidebar (left overlay)
- 'file-explorer': File explorer sidebar (right overlay)

Desktop retains independent boolean toggles since sidebars sit
alongside content rather than overlaying it.

Key changes:
- Created stores/panel-store.ts with unified state
- Deleted stores/sidebar-store.ts and explorer-sidebar-store.ts
- Updated all components to use new panel store
- Animation contexts derive isOpen from unified state
This commit is contained in:
Mohamed Boudra
2026-01-16 16:19:49 +07:00
parent b8f056060f
commit ed50c168ad
58 changed files with 1579 additions and 893 deletions
@@ -1,67 +0,0 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Platform } from "react-native";
type ExplorerTab = "changes" | "files";
export type ViewMode = "list" | "grid";
export type SortOption = "name" | "modified" | "size";
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400;
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
export const MAX_EXPLORER_SIDEBAR_WIDTH = 800;
const DEFAULT_OPEN = Platform.OS === "web";
interface ExplorerSidebarState {
isOpen: boolean;
activeTab: ExplorerTab;
width: number;
viewMode: ViewMode;
sortOption: SortOption;
toggle: () => void;
open: () => void;
close: () => void;
setActiveTab: (tab: ExplorerTab) => void;
setWidth: (width: number) => void;
setViewMode: (mode: ViewMode) => void;
setSortOption: (option: SortOption) => void;
}
function clampWidth(width: number): number {
return Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, width));
}
export const useExplorerSidebarStore = create<ExplorerSidebarState>()(
persist(
(set) => ({
isOpen: DEFAULT_OPEN,
activeTab: "changes",
width: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
viewMode: "list",
sortOption: "name",
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false }),
setActiveTab: (tab) => set({ activeTab: tab }),
setWidth: (width) => set({ width: clampWidth(width) }),
setViewMode: (mode) => set({ viewMode: mode }),
setSortOption: (option) => set({ sortOption: option }),
}),
{
name: "explorer-sidebar-state",
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
isOpen: state.isOpen,
activeTab: state.activeTab,
width: state.width,
viewMode: state.viewMode,
sortOption: state.sortOption,
}),
}
)
);
export function useExplorerSidebar() {
return useExplorerSidebarStore();
}
+219
View File
@@ -0,0 +1,219 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Platform } from "react-native";
/**
* Mobile panel state machine.
*
* On mobile, exactly one panel can be visible at a time:
* - 'agent': Main agent view (no overlay panel)
* - 'agent-list': Agent list sidebar (left overlay)
* - 'file-explorer': File explorer sidebar (right overlay)
*
* This makes impossible states unrepresentable - you cannot have both
* sidebars open at the same time on mobile.
*/
type MobilePanelView = "agent" | "agent-list" | "file-explorer";
/**
* Desktop sidebar state.
*
* On desktop, sidebars are independent toggleable panels that don't overlay
* the main content - they sit alongside it. Both can be open simultaneously.
*/
interface DesktopSidebarState {
agentListOpen: boolean;
fileExplorerOpen: boolean;
}
type ExplorerTab = "changes" | "files";
export type ViewMode = "list" | "grid";
export type SortOption = "name" | "modified" | "size";
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400;
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
export const MAX_EXPLORER_SIDEBAR_WIDTH = 800;
interface PanelState {
// Mobile: which panel is currently shown
mobileView: MobilePanelView;
// Desktop: independent sidebar toggles
desktop: DesktopSidebarState;
// File explorer settings (shared between mobile/desktop)
explorerTab: ExplorerTab;
explorerWidth: number;
explorerViewMode: ViewMode;
explorerSortOption: SortOption;
// Actions
openAgentList: () => void;
openFileExplorer: () => void;
closeToAgent: () => void;
toggleAgentList: () => void;
toggleFileExplorer: () => void;
// File explorer settings actions
setExplorerTab: (tab: ExplorerTab) => void;
setExplorerWidth: (width: number) => void;
setExplorerViewMode: (mode: ViewMode) => void;
setExplorerSortOption: (option: SortOption) => void;
}
function clampWidth(width: number): number {
return Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, width));
}
const DEFAULT_DESKTOP_OPEN = Platform.OS === "web";
export const usePanelStore = create<PanelState>()(
persist(
(set) => ({
// Mobile always starts at agent view
mobileView: "agent",
// Desktop defaults based on platform
desktop: {
agentListOpen: DEFAULT_DESKTOP_OPEN,
fileExplorerOpen: DEFAULT_DESKTOP_OPEN,
},
// File explorer defaults
explorerTab: "changes",
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
explorerViewMode: "list",
explorerSortOption: "name",
openAgentList: () =>
set((state) => ({
mobileView: "agent-list",
desktop: { ...state.desktop, agentListOpen: true },
})),
openFileExplorer: () =>
set((state) => ({
mobileView: "file-explorer",
desktop: { ...state.desktop, fileExplorerOpen: true },
})),
closeToAgent: () =>
set((state) => ({
mobileView: "agent",
// On desktop, closing depends on which panel triggered it
// This is called when closing via gesture/backdrop, so we close the currently active mobile panel
desktop: {
agentListOpen:
state.mobileView === "agent-list" ? false : state.desktop.agentListOpen,
fileExplorerOpen:
state.mobileView === "file-explorer" ? false : state.desktop.fileExplorerOpen,
},
})),
toggleAgentList: () =>
set((state) => {
// Mobile: toggle between agent and agent-list
const newMobileView = state.mobileView === "agent-list" ? "agent" : "agent-list";
return {
mobileView: newMobileView,
desktop: {
...state.desktop,
agentListOpen: !state.desktop.agentListOpen,
},
};
}),
toggleFileExplorer: () =>
set((state) => {
// Mobile: toggle between agent and file-explorer
const newMobileView = state.mobileView === "file-explorer" ? "agent" : "file-explorer";
return {
mobileView: newMobileView,
desktop: {
...state.desktop,
fileExplorerOpen: !state.desktop.fileExplorerOpen,
},
};
}),
setExplorerTab: (tab) => set({ explorerTab: tab }),
setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }),
setExplorerViewMode: (mode) => set({ explorerViewMode: mode }),
setExplorerSortOption: (option) => set({ explorerSortOption: option }),
}),
{
name: "panel-state",
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
mobileView: state.mobileView,
desktop: state.desktop,
explorerTab: state.explorerTab,
explorerWidth: state.explorerWidth,
explorerViewMode: state.explorerViewMode,
explorerSortOption: state.explorerSortOption,
}),
}
)
);
/**
* Hook that provides platform-aware panel state.
*
* On mobile, uses the state machine (mobileView).
* On desktop, uses independent booleans (desktop.agentListOpen, desktop.fileExplorerOpen).
*
* @param isMobile - Whether the current breakpoint is mobile
*/
export function usePanelState(isMobile: boolean) {
const store = usePanelStore();
if (isMobile) {
return {
isAgentListOpen: store.mobileView === "agent-list",
isFileExplorerOpen: store.mobileView === "file-explorer",
openAgentList: store.openAgentList,
openFileExplorer: store.openFileExplorer,
closeAgentList: store.closeToAgent,
closeFileExplorer: store.closeToAgent,
toggleAgentList: store.toggleAgentList,
toggleFileExplorer: store.toggleFileExplorer,
// Explorer settings
explorerTab: store.explorerTab,
explorerWidth: store.explorerWidth,
explorerViewMode: store.explorerViewMode,
explorerSortOption: store.explorerSortOption,
setExplorerTab: store.setExplorerTab,
setExplorerWidth: store.setExplorerWidth,
setExplorerViewMode: store.setExplorerViewMode,
setExplorerSortOption: store.setExplorerSortOption,
};
}
// Desktop: independent toggles
return {
isAgentListOpen: store.desktop.agentListOpen,
isFileExplorerOpen: store.desktop.fileExplorerOpen,
openAgentList: store.openAgentList,
openFileExplorer: store.openFileExplorer,
closeAgentList: () =>
usePanelStore.setState((state) => ({
desktop: { ...state.desktop, agentListOpen: false },
})),
closeFileExplorer: () =>
usePanelStore.setState((state) => ({
desktop: { ...state.desktop, fileExplorerOpen: false },
})),
toggleAgentList: store.toggleAgentList,
toggleFileExplorer: store.toggleFileExplorer,
// Explorer settings
explorerTab: store.explorerTab,
explorerWidth: store.explorerWidth,
explorerViewMode: store.explorerViewMode,
explorerSortOption: store.explorerSortOption,
setExplorerTab: store.setExplorerTab,
setExplorerWidth: store.setExplorerWidth,
setExplorerViewMode: store.setExplorerViewMode,
setExplorerSortOption: store.setExplorerSortOption,
};
}
+1 -1
View File
@@ -196,7 +196,7 @@ export interface SessionState {
git?: any;
worktreeName?: string;
requestId?: string;
}) => Promise<void>;
}) => Promise<unknown>;
setAgentMode: (agentId: string, modeId: string) => void;
respondToPermission: (agentId: string, requestId: string, response: any) => void;
ensureAgentIsInitialized: (agentId: string) => Promise<void>;
-33
View File
@@ -1,33 +0,0 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Platform } from "react-native";
interface SidebarState {
isOpen: boolean;
toggle: () => void;
open: () => void;
close: () => void;
}
const DEFAULT_OPEN = Platform.OS === "web";
export const useSidebarStore = create<SidebarState>()(
persist(
(set) => ({
isOpen: DEFAULT_OPEN,
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false }),
}),
{
name: "sidebar-state",
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({ isOpen: state.isOpen }),
}
)
);
export function useSidebar() {
return useSidebarStore();
}