Fix mobile autocomplete sidebar layering
This commit is contained in:
+15
-6
@@ -40,10 +40,12 @@ Two escape hatches in the codebase:
|
||||
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
|
||||
(the composer's input must stay focused so the user keeps typing).
|
||||
- **`<Portal>` from `@gorhom/portal`** (hover-card, autocomplete-popover) —
|
||||
hoists the React subtree to a fixed mount point (`PortalHost name="root"` in
|
||||
`app/_layout.tsx`) whose bounds cover the screen. Same window, same IME,
|
||||
hit-test works because the new parent is full-screen. This is the right
|
||||
default when you must keep IME attachment.
|
||||
hoists the React subtree to a fixed mount point whose bounds cover the
|
||||
screen. Same window, same IME, hit-test works because the new parent is
|
||||
full-screen. This is the right default when you must keep IME attachment.
|
||||
Choose the host by layer: app-global overlays use the root host; composer
|
||||
autocomplete uses the content floating-panel host so sliding sidebars cover
|
||||
it.
|
||||
|
||||
Choose Modal vs Portal by whether you need the underlying input to keep its
|
||||
keyboard.
|
||||
@@ -63,8 +65,15 @@ quietly relying on:
|
||||
- **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with
|
||||
`translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat
|
||||
content has the same transform applied (`agent-panel.tsx:939`). They move
|
||||
together because they share the SharedValue. A portal'd popover is at the
|
||||
app root — it does not get that transform unless you apply it yourself.
|
||||
together because they share the SharedValue. A portal'd popover is outside
|
||||
the composer tree — it does not get that transform unless you apply it
|
||||
yourself.
|
||||
- **Layering.** The default root host renders after app content, so it sits
|
||||
above compact sidebars. Autocomplete should use
|
||||
`useFloatingPanelPortalHostName()` and render through the current
|
||||
`FloatingPanelPortalHost` instead. The app shell provides a default content
|
||||
host; workspace screens provide a per-workspace host between the center pane
|
||||
and explorer sidebar so both compact sidebars cover autocomplete.
|
||||
|
||||
The fix for transforms is Gotcha 3.
|
||||
|
||||
|
||||
@@ -89,6 +89,16 @@ interface PopoverFrameRecorderWindow extends Window {
|
||||
__stopComposerAutocompleteFrameRecorder?: () => void;
|
||||
}
|
||||
|
||||
async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
|
||||
return page.evaluate(
|
||||
([pointX, pointY]) => {
|
||||
const element = document.elementFromPoint(pointX, pointY);
|
||||
return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null;
|
||||
},
|
||||
[x, y],
|
||||
);
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
@@ -165,7 +175,10 @@ async function cleanupWithin(timeoutMs: number, cleanup: () => Promise<void>): P
|
||||
await Promise.race([operation, new Promise<void>((resolve) => setTimeout(resolve, timeoutMs))]);
|
||||
}
|
||||
|
||||
async function openReadyMockAgent(page: Page): Promise<{
|
||||
async function openReadyMockAgent(
|
||||
page: Page,
|
||||
options?: { expectWorkspaceTab?: boolean },
|
||||
): Promise<{
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
const repo = await createTempGitRepo("autocomplete-popover-");
|
||||
@@ -189,7 +202,9 @@ async function openReadyMockAgent(page: Page): Promise<{
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
if (options?.expectWorkspaceTab !== false) {
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
}
|
||||
await expectComposerVisible(page);
|
||||
return {
|
||||
cleanup: async () => {
|
||||
@@ -455,4 +470,40 @@ test.describe("Composer autocomplete", () => {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("compact sidebar layering", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
|
||||
test("keeps the mobile agent sidebar above autocomplete", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page, { expectWorkspaceTab: false });
|
||||
|
||||
try {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
await input.fill("/");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
|
||||
|
||||
const popoverBox = await popover.boundingBox();
|
||||
expect(popoverBox).not.toBeNull();
|
||||
|
||||
const topTestId = await getTopTestIdAtPoint(
|
||||
page,
|
||||
popoverBox!.x + popoverBox!.width / 2,
|
||||
popoverBox!.y + popoverBox!.height / 2,
|
||||
);
|
||||
|
||||
expect(topTestId).not.toBe("composer-autocomplete-popover");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import { LeftSidebar } from "@/components/left-sidebar";
|
||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import {
|
||||
@@ -457,6 +458,7 @@ function AppContainer({
|
||||
)}
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</View>
|
||||
<FloatingPanelPortalHost />
|
||||
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<RosettaCalloutSource />
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete";
|
||||
import { useFloatingPanelPortalHostName } from "@/components/ui/floating-panel-portal";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
@@ -56,6 +57,7 @@ export function AutocompletePopover({
|
||||
const [anchorRect, setAnchorRect] = useState<Rect | null>(null);
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
const insets = useSafeAreaInsets();
|
||||
const portalHostName = useFloatingPanelPortalHostName();
|
||||
|
||||
const { height: rawKeyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
const bottomInsetSV = useSharedValue(insets.bottom);
|
||||
@@ -127,7 +129,7 @@ export function AutocompletePopover({
|
||||
if (!visible || !anchorRect || !baseStyle) return null;
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Portal hostName={portalHostName}>
|
||||
<View style={styles.overlay} pointerEvents="box-none">
|
||||
<Animated.View testID="composer-autocomplete-popover" style={composedStyle}>
|
||||
<Autocomplete
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createContext, type ReactElement, type ReactNode, useContext } from "react";
|
||||
import { PortalHost } from "@gorhom/portal";
|
||||
|
||||
export const DEFAULT_FLOATING_PANEL_PORTAL_HOST = "content-floating-panels";
|
||||
|
||||
const FloatingPanelPortalHostNameContext = createContext(DEFAULT_FLOATING_PANEL_PORTAL_HOST);
|
||||
|
||||
export function FloatingPanelPortalHostNameProvider({
|
||||
hostName,
|
||||
children,
|
||||
}: {
|
||||
hostName: string;
|
||||
children: ReactNode;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<FloatingPanelPortalHostNameContext.Provider value={hostName}>
|
||||
{children}
|
||||
</FloatingPanelPortalHostNameContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFloatingPanelPortalHostName(): string {
|
||||
return useContext(FloatingPanelPortalHostNameContext);
|
||||
}
|
||||
|
||||
export function FloatingPanelPortalHost({
|
||||
name = DEFAULT_FLOATING_PANEL_PORTAL_HOST,
|
||||
}: {
|
||||
name?: string;
|
||||
}): ReactElement {
|
||||
return <PortalHost name={name} />;
|
||||
}
|
||||
@@ -53,6 +53,10 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
FloatingPanelPortalHost,
|
||||
FloatingPanelPortalHostNameProvider,
|
||||
} from "@/components/ui/floating-panel-portal";
|
||||
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { SplitContainer } from "@/components/split-container";
|
||||
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
|
||||
@@ -171,6 +175,7 @@ import {
|
||||
} from "@/workspace/file-open";
|
||||
|
||||
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
|
||||
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels";
|
||||
const EMPTY_UI_TABS: WorkspaceTab[] = [];
|
||||
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
|
||||
const EMPTY_PINNED_AGENT_IDS = new Set<string>();
|
||||
@@ -3179,6 +3184,11 @@ function WorkspaceScreenContent({
|
||||
() => isFocusModeEnabled && !isMobile,
|
||||
[isFocusModeEnabled, isMobile],
|
||||
);
|
||||
const workspaceFloatingPanelPortalHostName = useMemo(
|
||||
() =>
|
||||
`${WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX}:${normalizedServerId}:${normalizedWorkspaceId}`,
|
||||
[normalizedServerId, normalizedWorkspaceId],
|
||||
);
|
||||
const desktopContent = useMemo(() => {
|
||||
if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) {
|
||||
return content;
|
||||
@@ -3254,6 +3264,120 @@ function WorkspaceScreenContent({
|
||||
renderSplitPaneEmptyState,
|
||||
]);
|
||||
|
||||
const workspaceCenterColumn = (
|
||||
<View style={styles.centerColumn}>
|
||||
{showScreenHeader && (
|
||||
<ScreenHeader
|
||||
onRowLayout={onHeaderLayout}
|
||||
left={
|
||||
<>
|
||||
<SidebarMenuToggle />
|
||||
<WorkspaceHeaderTitleBar
|
||||
isLoading={isWorkspaceHeaderLoading}
|
||||
title={workspaceHeaderTitle}
|
||||
subtitle={workspaceHeaderSubtitle}
|
||||
showSubtitle={shouldShowWorkspaceHeaderSubtitle}
|
||||
currentBranchName={currentBranchName}
|
||||
isGitCheckout={isGitCheckout}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
workspaceScripts={workspaceScripts}
|
||||
liveTerminalIds={liveTerminalIds}
|
||||
showWorkspaceSetup={showWorkspaceSetup}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
isMobile={isMobile}
|
||||
createTerminalDisabled={createTerminalDisabled}
|
||||
importAgentDisabled={!canOpenImportSheet}
|
||||
menuNewAgentIcon={menuNewAgentIcon}
|
||||
menuNewTerminalIcon={menuNewTerminalIcon}
|
||||
menuNewBrowserIcon={MENU_NEW_BROWSER_ICON}
|
||||
menuImportIcon={MENU_IMPORT_ICON}
|
||||
menuCopyIcon={menuCopyIcon}
|
||||
menuSettingsIcon={menuSettingsIcon}
|
||||
onCreateDraftTab={handleCreateDraftTab}
|
||||
onCreateTerminal={handleCreateTerminal}
|
||||
onCreateBrowser={handleCreateBrowserTab}
|
||||
onOpenImportSheet={openImportSheet}
|
||||
onCopyWorkspacePath={handleCopyWorkspacePath}
|
||||
onCopyBranchName={handleCopyBranchName}
|
||||
onOpenSetupTab={handleOpenSetupTab}
|
||||
onScriptTerminalStarted={handleScriptTerminalStarted}
|
||||
onViewScriptTerminal={handleViewScriptTerminal}
|
||||
onOpenUrlInBrowserTab={handleOpenUrlInBrowserTab}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
right={headerRight}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMobile ? (
|
||||
<MobileWorkspaceTabSwitcher
|
||||
tabs={tabs}
|
||||
activeTabKey={activeTabKey}
|
||||
activeTab={activeTabDescriptor}
|
||||
tabSwitcherOptions={tabSwitcherOptions}
|
||||
tabByKey={tabByKey}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
onSelectSwitcherTab={handleSelectSwitcherTab}
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onReloadAgent={handleReloadAgent}
|
||||
onRenameTab={handleRenameTab}
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCloseTabsAbove={handleCloseTabsToLeft}
|
||||
onCloseTabsBelow={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{shouldRenderDesktopPaneFallback ? (
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={focusedPaneIdOrUndefined}
|
||||
isFocused={isRouteFocused}
|
||||
tabs={desktopTabRowItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={navigateToTabId}
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onReloadAgent={handleReloadAgent}
|
||||
onRenameTab={handleRenameTab}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
onCreateDraftTab={handleCreateDraftTab}
|
||||
onCreateTerminalTab={handleCreateTerminal}
|
||||
onCreateBrowserTab={handleCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
disableCreateTerminal={createTerminalMutation.isPending}
|
||||
isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null}
|
||||
onReorderTabs={handleReorderTabsInFocusedPane}
|
||||
onSplitRight={noop}
|
||||
onSplitDown={noop}
|
||||
showPaneSplitActions={false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<View style={styles.centerContent}>
|
||||
{isMobile ? (
|
||||
<GestureDetector
|
||||
gesture={explorerOpenGesture}
|
||||
touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}
|
||||
>
|
||||
<View style={styles.content}>{content}</View>
|
||||
</GestureDetector>
|
||||
) : (
|
||||
<View style={styles.content}>{desktopContent}</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
gatedWorkspaceScreen ?? (
|
||||
<WorkspaceFocusProvider workspaceKey={persistenceKey}>
|
||||
@@ -3265,117 +3389,11 @@ function WorkspaceScreenContent({
|
||||
isRouteFocused={isRouteFocused}
|
||||
/>
|
||||
<View style={styles.threePaneRow}>
|
||||
<View style={styles.centerColumn}>
|
||||
{showScreenHeader && (
|
||||
<ScreenHeader
|
||||
onRowLayout={onHeaderLayout}
|
||||
left={
|
||||
<>
|
||||
<SidebarMenuToggle />
|
||||
<WorkspaceHeaderTitleBar
|
||||
isLoading={isWorkspaceHeaderLoading}
|
||||
title={workspaceHeaderTitle}
|
||||
subtitle={workspaceHeaderSubtitle}
|
||||
showSubtitle={shouldShowWorkspaceHeaderSubtitle}
|
||||
currentBranchName={currentBranchName}
|
||||
isGitCheckout={isGitCheckout}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
workspaceScripts={workspaceScripts}
|
||||
liveTerminalIds={liveTerminalIds}
|
||||
showWorkspaceSetup={showWorkspaceSetup}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
isMobile={isMobile}
|
||||
createTerminalDisabled={createTerminalDisabled}
|
||||
importAgentDisabled={!canOpenImportSheet}
|
||||
menuNewAgentIcon={menuNewAgentIcon}
|
||||
menuNewTerminalIcon={menuNewTerminalIcon}
|
||||
menuNewBrowserIcon={MENU_NEW_BROWSER_ICON}
|
||||
menuImportIcon={MENU_IMPORT_ICON}
|
||||
menuCopyIcon={menuCopyIcon}
|
||||
menuSettingsIcon={menuSettingsIcon}
|
||||
onCreateDraftTab={handleCreateDraftTab}
|
||||
onCreateTerminal={handleCreateTerminal}
|
||||
onCreateBrowser={handleCreateBrowserTab}
|
||||
onOpenImportSheet={openImportSheet}
|
||||
onCopyWorkspacePath={handleCopyWorkspacePath}
|
||||
onCopyBranchName={handleCopyBranchName}
|
||||
onOpenSetupTab={handleOpenSetupTab}
|
||||
onScriptTerminalStarted={handleScriptTerminalStarted}
|
||||
onViewScriptTerminal={handleViewScriptTerminal}
|
||||
onOpenUrlInBrowserTab={handleOpenUrlInBrowserTab}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
right={headerRight}
|
||||
/>
|
||||
)}
|
||||
<FloatingPanelPortalHostNameProvider hostName={workspaceFloatingPanelPortalHostName}>
|
||||
{workspaceCenterColumn}
|
||||
</FloatingPanelPortalHostNameProvider>
|
||||
|
||||
{isMobile ? (
|
||||
<MobileWorkspaceTabSwitcher
|
||||
tabs={tabs}
|
||||
activeTabKey={activeTabKey}
|
||||
activeTab={activeTabDescriptor}
|
||||
tabSwitcherOptions={tabSwitcherOptions}
|
||||
tabByKey={tabByKey}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
onSelectSwitcherTab={handleSelectSwitcherTab}
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onReloadAgent={handleReloadAgent}
|
||||
onRenameTab={handleRenameTab}
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCloseTabsAbove={handleCloseTabsToLeft}
|
||||
onCloseTabsBelow={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{shouldRenderDesktopPaneFallback ? (
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={focusedPaneIdOrUndefined}
|
||||
isFocused={isRouteFocused}
|
||||
tabs={desktopTabRowItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={navigateToTabId}
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onReloadAgent={handleReloadAgent}
|
||||
onRenameTab={handleRenameTab}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
onCreateDraftTab={handleCreateDraftTab}
|
||||
onCreateTerminalTab={handleCreateTerminal}
|
||||
onCreateBrowserTab={handleCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
disableCreateTerminal={createTerminalMutation.isPending}
|
||||
isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null}
|
||||
onReorderTabs={handleReorderTabsInFocusedPane}
|
||||
onSplitRight={noop}
|
||||
onSplitDown={noop}
|
||||
showPaneSplitActions={false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<View style={styles.centerContent}>
|
||||
{isMobile ? (
|
||||
<GestureDetector
|
||||
gesture={explorerOpenGesture}
|
||||
touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}
|
||||
>
|
||||
<View style={styles.content}>{content}</View>
|
||||
</GestureDetector>
|
||||
) : (
|
||||
<View style={styles.content}>{desktopContent}</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<FloatingPanelPortalHost name={workspaceFloatingPanelPortalHostName} />
|
||||
|
||||
{showExplorerSidebar && workspaceDirectory ? (
|
||||
<ExplorerSidebar
|
||||
|
||||
Reference in New Issue
Block a user