Fix mobile sidebar regressions and workspace e2e
This commit is contained in:
@@ -248,7 +248,12 @@ test.describe("New workspace flow", () => {
|
|||||||
subtitle: openedProject.projectDisplayName,
|
subtitle: openedProject.projectDisplayName,
|
||||||
});
|
});
|
||||||
|
|
||||||
const agentTabs = page.locator('[data-testid^="workspace-tab-agent_"]');
|
const activeWorkspaceDeckEntry = page
|
||||||
|
.getByTestId(`workspace-deck-entry-${serverId}:${createdWorkspace.workspaceId}`)
|
||||||
|
.filter({ visible: true });
|
||||||
|
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const agentTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]');
|
||||||
await expect(agentTabs).toHaveCount(1, { timeout: 30_000 });
|
await expect(agentTabs).toHaveCount(1, { timeout: 30_000 });
|
||||||
|
|
||||||
// Workspace setup may auto-open a setup tab that steals focus,
|
// Workspace setup may auto-open a setup tab that steals focus,
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { act } from "@testing-library/react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { panelState, useSidebarWorkspacesListMock, theme } = vi.hoisted(() => {
|
||||||
|
const panelState = {
|
||||||
|
isOpen: false,
|
||||||
|
showMobileAgent: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
panelState,
|
||||||
|
useSidebarWorkspacesListMock: vi.fn(),
|
||||||
|
theme: {
|
||||||
|
spacing: { 0: 0, 0.5: 2, 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16, 5: 20 },
|
||||||
|
iconSize: { sm: 14, md: 18, lg: 22 },
|
||||||
|
borderWidth: { 1: 1 },
|
||||||
|
borderRadius: { sm: 4, md: 6, lg: 8, full: 999 },
|
||||||
|
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||||
|
fontWeight: { normal: "400", medium: "500", semibold: "600" },
|
||||||
|
colors: {
|
||||||
|
surfaceSidebar: "#111",
|
||||||
|
surface1: "#111",
|
||||||
|
surface2: "#222",
|
||||||
|
surface3: "#333",
|
||||||
|
surface4: "#444",
|
||||||
|
foreground: "#fff",
|
||||||
|
foregroundMuted: "#aaa",
|
||||||
|
border: "#555",
|
||||||
|
borderAccent: "#666",
|
||||||
|
accent: "#0a84ff",
|
||||||
|
accentForeground: "#fff",
|
||||||
|
palette: {
|
||||||
|
green: { 400: "#30d158" },
|
||||||
|
amber: { 500: "#ffd60a" },
|
||||||
|
red: { 500: "#ff453a" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
absoluteFillObject: {},
|
||||||
|
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||||
|
},
|
||||||
|
useUnistyles: () => ({ theme }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-reanimated", () => ({
|
||||||
|
default: {
|
||||||
|
View: "div",
|
||||||
|
},
|
||||||
|
Extrapolation: { CLAMP: "clamp" },
|
||||||
|
interpolate: () => 0,
|
||||||
|
runOnJS: (fn: (...args: unknown[]) => unknown) => fn,
|
||||||
|
useAnimatedStyle: (factory: () => unknown) => factory(),
|
||||||
|
useSharedValue: (value: unknown) => ({ value }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-gesture-handler", () => {
|
||||||
|
const chain = {
|
||||||
|
enabled: () => chain,
|
||||||
|
hitSlop: () => chain,
|
||||||
|
manualActivation: () => chain,
|
||||||
|
onTouchesDown: () => chain,
|
||||||
|
onTouchesMove: () => chain,
|
||||||
|
onStart: () => chain,
|
||||||
|
onUpdate: () => chain,
|
||||||
|
onEnd: () => chain,
|
||||||
|
onFinalize: () => chain,
|
||||||
|
withRef: () => chain,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
Gesture: { Pan: () => chain },
|
||||||
|
GestureDetector: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("lucide-react-native", () => {
|
||||||
|
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||||
|
React.createElement("span", { ...props, "data-icon": name });
|
||||||
|
return {
|
||||||
|
MessagesSquare: createIcon("MessagesSquare"),
|
||||||
|
Plus: createIcon("Plus"),
|
||||||
|
Settings: createIcon("Settings"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("expo-router", () => ({
|
||||||
|
router: { push: vi.fn() },
|
||||||
|
usePathname: () => "/hosts/srv",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-safe-area-context", () => ({
|
||||||
|
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/layout", () => ({
|
||||||
|
useIsCompactFormFactor: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/platform", () => ({
|
||||||
|
isWeb: true,
|
||||||
|
isNative: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/panel-store", () => ({
|
||||||
|
MIN_SIDEBAR_WIDTH: 260,
|
||||||
|
MAX_SIDEBAR_WIDTH: 420,
|
||||||
|
selectIsAgentListOpen: (state: typeof panelState) => state.isOpen,
|
||||||
|
usePanelStore: (selector: (state: typeof panelState) => unknown) => selector(panelState),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/runtime/host-runtime", () => ({
|
||||||
|
useHosts: () => [{ serverId: "srv", label: "Local" }],
|
||||||
|
useHostRuntimeSnapshot: () => ({ connectionStatus: "online" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-sidebar-workspaces-list", () => ({
|
||||||
|
useSidebarWorkspacesList: useSidebarWorkspacesListMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-sidebar-shortcut-model", () => ({
|
||||||
|
useSidebarShortcutModel: () => ({
|
||||||
|
collapsedProjectKeys: new Set<string>(),
|
||||||
|
shortcutIndexByWorkspaceKey: new Map<string, number>(),
|
||||||
|
toggleProjectCollapsed: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/contexts/sidebar-animation-context", () => ({
|
||||||
|
useSidebarAnimation: () => ({
|
||||||
|
translateX: { value: 0 },
|
||||||
|
backdropOpacity: { value: 0 },
|
||||||
|
windowWidth: 390,
|
||||||
|
animateToOpen: vi.fn(),
|
||||||
|
animateToClose: vi.fn(),
|
||||||
|
isGesturing: { value: false },
|
||||||
|
gestureAnimatingRef: { current: false },
|
||||||
|
closeGestureRef: { current: undefined },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-shortcut-keys", () => ({
|
||||||
|
useShortcutKeys: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/utils/desktop-window", () => ({
|
||||||
|
useWindowControlsPadding: () => ({ top: 0 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/utils/host-routes", () => ({
|
||||||
|
buildHostSessionsRoute: (serverId: string) => `/hosts/${serverId}/sessions`,
|
||||||
|
buildSettingsRoute: () => "/settings",
|
||||||
|
mapPathnameToServer: (_pathname: string, serverId: string) => `/hosts/${serverId}`,
|
||||||
|
parseServerIdFromPathname: () => "srv",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-open-project-picker", () => ({
|
||||||
|
useOpenProjectPicker: () => vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/sidebar/sidebar-header-row", () => ({
|
||||||
|
SidebarHeaderRow: ({ label }: { label: string }) => React.createElement("div", null, label),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./sidebar-workspace-list", () => ({
|
||||||
|
SidebarWorkspaceList: ({ projects }: { projects: Array<{ projectName: string }> }) =>
|
||||||
|
React.createElement(
|
||||||
|
"div",
|
||||||
|
{ "data-testid": "sidebar-workspace-list" },
|
||||||
|
projects.map((project) => project.projectName).join(","),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./sidebar-agent-list-skeleton", () => ({
|
||||||
|
SidebarAgentListSkeleton: () => React.createElement("div", null, "Loading"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/tooltip", () => ({
|
||||||
|
Tooltip: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement("div", null, children),
|
||||||
|
TooltipContent: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement("div", null, children),
|
||||||
|
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement("div", null, children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/shortcut", () => ({
|
||||||
|
Shortcut: () => React.createElement("span", null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/combobox", () => ({
|
||||||
|
Combobox: () => null,
|
||||||
|
ComboboxItem: ({ label }: { label: string }) => React.createElement("div", null, label),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.stubGlobal("React", React);
|
||||||
|
|
||||||
|
import { LeftSidebar } from "./left-sidebar";
|
||||||
|
|
||||||
|
describe("LeftSidebar", () => {
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
panelState.isOpen = false;
|
||||||
|
panelState.showMobileAgent.mockReset();
|
||||||
|
useSidebarWorkspacesListMock.mockReset();
|
||||||
|
useSidebarWorkspacesListMock.mockReturnValue({
|
||||||
|
projects: [{ projectKey: "project-1", projectName: "Project 1", workspaces: [] }],
|
||||||
|
isInitialLoad: false,
|
||||||
|
isRevalidating: false,
|
||||||
|
refreshAll: vi.fn(),
|
||||||
|
});
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root = null;
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the mobile workspace list subscribed while the sidebar is hidden", async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<LeftSidebar />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useSidebarWorkspacesListMock).toHaveBeenLastCalledWith({
|
||||||
|
serverId: "srv",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -191,7 +191,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
|
|
||||||
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
||||||
serverId: activeServerId,
|
serverId: activeServerId,
|
||||||
enabled: isOpen,
|
enabled: isCompactLayout || isOpen,
|
||||||
});
|
});
|
||||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||||
useSidebarShortcutModel(projects);
|
useSidebarShortcutModel(projects);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from "react";
|
|||||||
import { Gesture } from "react-native-gesture-handler";
|
import { Gesture } from "react-native-gesture-handler";
|
||||||
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
||||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
|
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||||
|
|
||||||
interface UseExplorerOpenGestureParams {
|
interface UseExplorerOpenGestureParams {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -19,6 +20,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
|||||||
gestureAnimatingRef,
|
gestureAnimatingRef,
|
||||||
openGestureRef,
|
openGestureRef,
|
||||||
} = useExplorerSidebarAnimation();
|
} = useExplorerSidebarAnimation();
|
||||||
|
const { openGestureRef: leftOpenGestureRef } = useSidebarAnimation();
|
||||||
const touchStartX = useSharedValue(0);
|
const touchStartX = useSharedValue(0);
|
||||||
const touchStartY = useSharedValue(0);
|
const touchStartY = useSharedValue(0);
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
|||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
.withRef(openGestureRef)
|
.withRef(openGestureRef)
|
||||||
|
.simultaneousWithExternalGesture(leftOpenGestureRef)
|
||||||
.enabled(enabled)
|
.enabled(enabled)
|
||||||
.manualActivation(true)
|
.manualActivation(true)
|
||||||
.onTouchesDown((event) => {
|
.onTouchesDown((event) => {
|
||||||
@@ -109,6 +112,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
|||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
openGestureRef,
|
openGestureRef,
|
||||||
|
leftOpenGestureRef,
|
||||||
handleGestureOpen,
|
handleGestureOpen,
|
||||||
touchStartX,
|
touchStartX,
|
||||||
touchStartY,
|
touchStartY,
|
||||||
|
|||||||
Reference in New Issue
Block a user