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