Files
paseo/packages/app/src/stores/panel-store.ts
T

475 lines
17 KiB
TypeScript

import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Platform } from "react-native";
import {
buildExplorerCheckoutKey,
coerceExplorerTabForCheckout,
isExplorerTab,
resolveExplorerTabForCheckout,
type ExplorerTab,
} from "./explorer-tab-memory";
export type { ExplorerTab } from "./explorer-tab-memory";
/**
* 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;
}
export type SortOption = "name" | "modified" | "size";
export interface ExplorerCheckoutContext {
serverId: string;
cwd: string;
isGit: boolean;
}
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = Platform.OS === "web" ? 640 : 400;
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
// Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint.
export const MAX_EXPLORER_SIDEBAR_WIDTH = 2000;
export const DEFAULT_EXPLORER_FILES_SPLIT_RATIO = 0.38;
export const MIN_EXPLORER_FILES_SPLIT_RATIO = 0.2;
export const MAX_EXPLORER_FILES_SPLIT_RATIO = 0.8;
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logPanelTransition(
action: string,
details: Record<string, unknown>
): void {
if (!IS_DEV) {
return;
}
const stack =
new Error().stack
?.split("\n")
.slice(2, 8)
.join("\n") ?? "stack unavailable";
console.log(`[PanelStore] ${action}`, details, stack);
}
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;
explorerTabByCheckout: Record<string, ExplorerTab>;
activeExplorerCheckout: ExplorerCheckoutContext | null;
explorerWidth: number;
explorerSortOption: SortOption;
explorerFilesSplitRatio: number;
// Actions
openAgentList: () => void;
openFileExplorer: () => void;
closeFileExplorer: () => void;
closeToAgent: () => void;
toggleAgentList: () => void;
toggleFileExplorer: () => void;
// File explorer settings actions
setExplorerTab: (tab: ExplorerTab) => void;
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void;
setExplorerWidth: (width: number) => void;
setExplorerSortOption: (option: SortOption) => void;
setExplorerFilesSplitRatio: (ratio: number) => void;
}
function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) {
return min;
}
return Math.max(min, Math.min(max, value));
}
function clampWidth(width: number): number {
return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH);
}
function clampExplorerFilesSplitRatio(ratio: number): number {
return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO);
}
function resolveExplorerTabFromActiveCheckout(state: PanelState): ExplorerTab | null {
if (!state.activeExplorerCheckout) {
return null;
}
return resolveExplorerTabForCheckout({
serverId: state.activeExplorerCheckout.serverId,
cwd: state.activeExplorerCheckout.cwd,
isGit: state.activeExplorerCheckout.isGit,
explorerTabByCheckout: state.explorerTabByCheckout,
});
}
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",
explorerTabByCheckout: {},
activeExplorerCheckout: null,
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
explorerSortOption: "name",
explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
openAgentList: () =>
set((state) => {
const nextState = {
mobileView: "agent-list" as const,
desktop: { ...state.desktop, agentListOpen: true },
};
logPanelTransition("openAgentList", {
fromMobileView: state.mobileView,
toMobileView: nextState.mobileView,
fromDesktopAgentListOpen: state.desktop.agentListOpen,
toDesktopAgentListOpen: nextState.desktop.agentListOpen,
});
return nextState;
}),
openFileExplorer: () =>
set((state) => {
const resolvedTab = resolveExplorerTabFromActiveCheckout(state);
const nextMobileView: MobilePanelView = "file-explorer";
const nextDesktop = { ...state.desktop, fileExplorerOpen: true };
const nextState = {
mobileView: nextMobileView,
desktop: nextDesktop,
...(resolvedTab ? { explorerTab: resolvedTab } : {}),
};
logPanelTransition("openFileExplorer", {
fromMobileView: state.mobileView,
toMobileView: nextMobileView,
fromDesktopFileExplorerOpen: state.desktop.fileExplorerOpen,
toDesktopFileExplorerOpen: nextDesktop.fileExplorerOpen,
resolvedTab: resolvedTab ?? null,
activeCheckout: state.activeExplorerCheckout,
});
return nextState;
}),
closeFileExplorer: () =>
set((state) => {
const nextState = {
mobileView:
state.mobileView === "file-explorer" ? ("agent" as const) : state.mobileView,
desktop: {
...state.desktop,
fileExplorerOpen: false,
},
};
logPanelTransition("closeFileExplorer", {
fromMobileView: state.mobileView,
toMobileView: nextState.mobileView,
fromDesktopFileExplorerOpen: state.desktop.fileExplorerOpen,
toDesktopFileExplorerOpen: nextState.desktop.fileExplorerOpen,
});
return nextState;
}),
closeToAgent: () =>
set((state) => {
const nextState = {
mobileView: "agent" as const,
// 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,
},
};
logPanelTransition("closeToAgent", {
fromMobileView: state.mobileView,
toMobileView: nextState.mobileView,
fromDesktopAgentListOpen: state.desktop.agentListOpen,
toDesktopAgentListOpen: nextState.desktop.agentListOpen,
fromDesktopFileExplorerOpen: state.desktop.fileExplorerOpen,
toDesktopFileExplorerOpen: nextState.desktop.fileExplorerOpen,
});
return nextState;
}),
toggleAgentList: () =>
set((state) => {
// Mobile: toggle between agent and agent-list
const newMobileView: MobilePanelView =
state.mobileView === "agent-list" ? "agent" : "agent-list";
const nextState = {
mobileView: newMobileView,
desktop: {
...state.desktop,
agentListOpen: !state.desktop.agentListOpen,
},
};
logPanelTransition("toggleAgentList", {
fromMobileView: state.mobileView,
toMobileView: nextState.mobileView,
fromDesktopAgentListOpen: state.desktop.agentListOpen,
toDesktopAgentListOpen: nextState.desktop.agentListOpen,
});
return nextState;
}),
toggleFileExplorer: () =>
set((state) => {
// Mobile: toggle between agent and file-explorer
const willOpenMobile = state.mobileView !== "file-explorer";
const willOpenDesktop = !state.desktop.fileExplorerOpen;
const nextMobileView: MobilePanelView = willOpenMobile
? "file-explorer"
: "agent";
const nextDesktop = {
...state.desktop,
fileExplorerOpen: willOpenDesktop,
};
const nextState: Pick<PanelState, "mobileView" | "desktop"> &
Partial<Pick<PanelState, "explorerTab">> = {
mobileView: nextMobileView,
desktop: nextDesktop,
};
let resolvedTab: ExplorerTab | null = null;
if (willOpenMobile || willOpenDesktop) {
resolvedTab = resolveExplorerTabFromActiveCheckout(state);
if (resolvedTab) {
nextState.explorerTab = resolvedTab;
}
}
logPanelTransition("toggleFileExplorer", {
fromMobileView: state.mobileView,
toMobileView: nextMobileView,
fromDesktopFileExplorerOpen: state.desktop.fileExplorerOpen,
toDesktopFileExplorerOpen: nextDesktop.fileExplorerOpen,
willOpenMobile,
willOpenDesktop,
resolvedTab: resolvedTab ?? null,
activeCheckout: state.activeExplorerCheckout,
});
return nextState;
}),
setExplorerTab: (tab) => set({ explorerTab: tab }),
setExplorerTabForCheckout: ({ serverId, cwd, isGit, tab }) =>
set((state) => {
const resolvedTab = coerceExplorerTabForCheckout(tab, isGit);
const key = buildExplorerCheckoutKey(serverId, cwd);
const nextState: Partial<PanelState> = { explorerTab: resolvedTab };
if (key) {
const current = state.explorerTabByCheckout[key];
if (current !== resolvedTab) {
nextState.explorerTabByCheckout = {
...state.explorerTabByCheckout,
[key]: resolvedTab,
};
}
}
return nextState;
}),
activateExplorerTabForCheckout: (checkout) =>
set((state) => ({
activeExplorerCheckout: checkout,
explorerTab: resolveExplorerTabForCheckout({
serverId: checkout.serverId,
cwd: checkout.cwd,
isGit: checkout.isGit,
explorerTabByCheckout: state.explorerTabByCheckout,
}),
})),
setActiveExplorerCheckout: (checkout) =>
set((state) => {
const current = state.activeExplorerCheckout;
if (
current?.serverId === checkout?.serverId &&
current?.cwd === checkout?.cwd &&
current?.isGit === checkout?.isGit
) {
return state;
}
return { activeExplorerCheckout: checkout };
}),
setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }),
setExplorerSortOption: (option) => set({ explorerSortOption: option }),
setExplorerFilesSplitRatio: (ratio) =>
set({
explorerFilesSplitRatio: Number.isFinite(ratio)
? clampExplorerFilesSplitRatio(ratio)
: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
}),
}),
{
name: "panel-state",
version: 5,
storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState, version) => {
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
if (version < 2) {
if (
Platform.OS === "web" &&
typeof state.explorerWidth === "number" &&
state.explorerWidth === 400
) {
state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH;
}
if (typeof state.explorerFilesSplitRatio !== "number") {
state.explorerFilesSplitRatio = DEFAULT_EXPLORER_FILES_SPLIT_RATIO;
} else {
state.explorerFilesSplitRatio = clampExplorerFilesSplitRatio(
state.explorerFilesSplitRatio
);
}
}
if (version < 3) {
if (
Platform.OS === "web" &&
typeof state.explorerWidth === "number" &&
(state.explorerWidth === 400 || state.explorerWidth === 520)
) {
state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH;
}
}
if (!isExplorerTab(state.explorerTab)) {
state.explorerTab = "changes";
}
if (version < 4 || typeof state.explorerTabByCheckout !== "object" || !state.explorerTabByCheckout) {
state.explorerTabByCheckout = {};
} else {
const entries = Object.entries(state.explorerTabByCheckout as Record<string, unknown>);
const next: Record<string, ExplorerTab> = {};
for (const [key, value] of entries) {
if (!isExplorerTab(value)) {
continue;
}
next[key] = value;
}
state.explorerTabByCheckout = next;
}
state.activeExplorerCheckout = null;
return state as PanelState;
},
partialize: (state) => ({
mobileView: state.mobileView,
desktop: state.desktop,
explorerTab: state.explorerTab,
explorerTabByCheckout: state.explorerTabByCheckout,
explorerWidth: state.explorerWidth,
explorerSortOption: state.explorerSortOption,
explorerFilesSplitRatio: state.explorerFilesSplitRatio,
}),
}
)
);
/**
* 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,
explorerTabByCheckout: store.explorerTabByCheckout,
explorerWidth: store.explorerWidth,
explorerSortOption: store.explorerSortOption,
explorerFilesSplitRatio: store.explorerFilesSplitRatio,
setExplorerTab: store.setExplorerTab,
setExplorerTabForCheckout: store.setExplorerTabForCheckout,
activateExplorerTabForCheckout: store.activateExplorerTabForCheckout,
setActiveExplorerCheckout: store.setActiveExplorerCheckout,
setExplorerWidth: store.setExplorerWidth,
setExplorerSortOption: store.setExplorerSortOption,
setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio,
};
}
// 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,
explorerTabByCheckout: store.explorerTabByCheckout,
explorerWidth: store.explorerWidth,
explorerSortOption: store.explorerSortOption,
explorerFilesSplitRatio: store.explorerFilesSplitRatio,
setExplorerTab: store.setExplorerTab,
setExplorerTabForCheckout: store.setExplorerTabForCheckout,
activateExplorerTabForCheckout: store.activateExplorerTabForCheckout,
setActiveExplorerCheckout: store.setActiveExplorerCheckout,
setExplorerWidth: store.setExplorerWidth,
setExplorerSortOption: store.setExplorerSortOption,
setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio,
};
}