Files
ai-code-app/src/app/main/layout/MainLayout.tsx

616 lines
20 KiB
TypeScript

import { Layout } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { useGestureLayer, useGesturePageState, useSearchLayer } from '../../../layer';
import { useAppStore } from '../../../store';
import { ChatRuntimeBridgeV2 } from '../ChatRuntimeBridgeV2';
import { syncAppConfigFromServer, useAppConfig } from '../appConfig';
import { useTokenAccess } from '../tokenAccess';
import { useUnreadCounts } from '../chatV2/hooks/useUnreadCounts';
import { matchesShortcut, isTypingTarget, scrollToElement } from '../mainView/utils';
import { MainContent } from '../MainContent';
import { MainHeader } from '../MainHeader';
import { MainSidebar } from '../MainSidebar';
import { appendPreviewRuntimeSearch, isPreviewRuntime, readPreviewRuntimeDeviceModeFromUrl } from '../previewRuntime';
import { buildSearchOptions } from './buildSearchOptions';
import { MainLayoutContextProvider } from './MainLayoutContext';
import { useMainLayoutData } from './useMainLayoutData';
import '../MainLayout.css';
import {
buildApiMenuItems,
buildApisPath,
buildChatMenuItems,
buildChatPath,
buildDocsMenuItems,
buildDocsPath,
buildPlanMenuItems,
buildPlansPath,
buildPlayMenuItems,
buildPlayPath,
buildSavedLayoutPath,
DOCS_DEFAULT_FOLDER,
PLAN_MENU_ANCHOR_IDS,
renderSidebarIntro,
resolveCurrentPageDescriptor,
resolvePlanQuickFilterMenu,
resolvePlayOpenKeys,
resolveSavedLayoutIdFromMenuKey,
resolveTopMenuPath,
type ApiSectionKey,
type ChatSectionKey,
type PlanSectionKey,
type PlaySidebarKey,
type TopMenuKey,
} from '../routes';
const E_READER_IMMERSIVE_BODY_CLASS = 'play-app-e-reader-immersive';
function parseRoute(pathname: string): {
topMenu: TopMenuKey;
docsMenu: string;
apiMenu: ApiSectionKey;
planMenu: PlanSectionKey;
chatMenu: ChatSectionKey;
playMenu: PlaySidebarKey;
} {
const segments = pathname.split('/').filter(Boolean);
const [top, first, second] = segments;
if (top === 'docs') {
return {
topMenu: 'docs',
docsMenu: first || DOCS_DEFAULT_FOLDER,
apiMenu: 'components',
planMenu: 'all',
chatMenu: 'live',
playMenu: 'layout',
};
}
if (top === 'apis' && (first === 'components' || first === 'widgets')) {
return {
topMenu: 'apis',
docsMenu: DOCS_DEFAULT_FOLDER,
apiMenu: first,
planMenu: 'all',
chatMenu: 'live',
playMenu: 'layout',
};
}
if (
top === 'plans' &&
(first === 'all' ||
first === 'in-progress' ||
first === 'done' ||
first === 'error' ||
first === 'release' ||
first === 'release-review' ||
first === 'board' ||
first === 'charts' ||
first === 'schedule' ||
first === 'history' ||
first === 'automation-type' ||
first === 'automation-context' ||
first === 'token-setting' ||
first === 'shared-resource' ||
first === 'server-command')
) {
return {
topMenu: 'plans',
docsMenu: DOCS_DEFAULT_FOLDER,
apiMenu: 'components',
planMenu: first,
chatMenu: 'live',
playMenu: 'layout',
};
}
if (
top === 'chat' &&
(first === 'live' ||
first === 'system' ||
first === 'changes' ||
first === 'resources' ||
first === 'errors' ||
first === 'manage' ||
first === 'manage-defaults' ||
first === 'manage-share')
) {
return {
topMenu: 'chat',
docsMenu: DOCS_DEFAULT_FOLDER,
apiMenu: 'components',
planMenu: 'all',
chatMenu: first,
playMenu: 'layout',
};
}
if (top === 'play' && (first === 'layout' || first === 'draw' || first === 'apps' || first === 'test' || first === 'cbt')) {
return {
topMenu: 'play',
docsMenu: DOCS_DEFAULT_FOLDER,
apiMenu: 'components',
planMenu: 'all',
chatMenu: 'live',
playMenu: first,
};
}
if (top === 'play' && first === 'layout-record' && second) {
return {
topMenu: 'play',
docsMenu: DOCS_DEFAULT_FOLDER,
apiMenu: 'components',
planMenu: 'all',
chatMenu: 'live',
playMenu: `layout-record:${second}`,
};
}
return {
topMenu: 'chat',
docsMenu: DOCS_DEFAULT_FOLDER,
apiMenu: 'components',
planMenu: 'all',
chatMenu: 'live',
playMenu: 'layout',
};
}
function isRestrictedTopMenu(topMenu: TopMenuKey, hasAccess: boolean) {
return !hasAccess && topMenu !== 'docs';
}
function getIsMobileViewport() {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return false;
}
if (readPreviewRuntimeDeviceModeFromUrl() === 'mobile') {
return true;
}
return window.matchMedia('(max-width: 768px)').matches;
}
function getIsSidebarOverlayViewport(topMenu: TopMenuKey) {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return false;
}
if (readPreviewRuntimeDeviceModeFromUrl() === 'mobile') {
return true;
}
return window.matchMedia(topMenu === 'chat' ? '(max-width: 1180px)' : '(max-width: 768px)').matches;
}
function resolveSidebarCollapsedForViewport(isSidebarOverlayViewport: boolean) {
if (!isSidebarOverlayViewport) {
return false;
}
return true;
}
function resolveSidebarOpenKeys(
topMenu: TopMenuKey,
hasAccess: boolean,
planMenu: PlanSectionKey,
chatMenu: ChatSectionKey,
) {
if (!hasAccess) {
return ['docs-group'];
}
if (topMenu === 'docs') {
return ['docs-group'];
}
if (topMenu === 'apis') {
return ['api-group'];
}
if (topMenu === 'play') {
return resolvePlayOpenKeys();
}
if (topMenu === 'plans') {
if (planMenu === 'server-command') {
return ['server-group'];
}
if (planMenu === 'token-setting' || planMenu === 'shared-resource') {
return ['token-management-group'];
}
return ['plan-group'];
}
if (chatMenu === 'errors') {
return ['app-log-group'];
}
return chatMenu === 'manage' || chatMenu === 'manage-defaults' ? ['chat-manage-group'] : ['chat-group'];
}
export function MainLayout() {
const previewRuntime = isPreviewRuntime();
const location = useLocation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { currentPage, focusedComponentId, setCurrentPage, setFocusedComponentId } = useAppStore();
const { hasAccess } = useTokenAccess();
const appConfig = useAppConfig();
const { openSearch, setOptions: setSearchOptions } = useSearchLayer();
const layoutData = useMainLayoutData();
const routeState = useMemo(() => parseRoute(location.pathname), [location.pathname]);
const [isMobileViewport, setIsMobileViewport] = useState(() => getIsMobileViewport());
const [isEReaderImmersiveActive, setIsEReaderImmersiveActive] = useState(() =>
typeof document !== 'undefined' ? document.body.classList.contains(E_READER_IMMERSIVE_BODY_CLASS) : false,
);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() =>
resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport(routeState.topMenu)),
);
const [isSidebarOverlayViewport, setIsSidebarOverlayViewport] = useState(() =>
getIsSidebarOverlayViewport(routeState.topMenu),
);
const [contentExpanded, setContentExpanded] = useState(false);
const [sidebarOpenKeys, setSidebarOpenKeys] = useState<string[]>(
resolveSidebarOpenKeys(routeState.topMenu, hasAccess, routeState.planMenu, routeState.chatMenu),
);
const [activePlanQuickFilter, setActivePlanQuickFilter] = useState<
'working' | 'release-pending-main' | 'automation-failed' | null
>(routeState.planMenu === 'release' ? 'release-pending-main' : null);
const [planQuickFilterRequestKey, setPlanQuickFilterRequestKey] = useState(routeState.planMenu === 'release' ? 1 : 0);
const { componentSampleEntries, widgetSampleEntries, componentSamples, widgetSamples, docsDocuments, savedLayouts, savedLayoutsReady, setSavedLayouts, docFolders } = layoutData;
const { chatUnreadCount } = useUnreadCounts();
const navigateWithinApp = (path: string, options?: { replace?: boolean; resetSearch?: boolean }) => {
const baseSearch = options?.resetSearch ? '' : location.search;
const nextPath = previewRuntime ? appendPreviewRuntimeSearch(path, baseSearch) : path;
navigate(nextPath, options?.replace == null ? undefined : { replace: options.replace });
};
useEffect(() => {
void syncAppConfigFromServer();
}, []);
useEffect(() => {
if (typeof document === 'undefined') {
return undefined;
}
const body = document.body;
const syncEReaderImmersiveState = () => {
setIsEReaderImmersiveActive(body.classList.contains(E_READER_IMMERSIVE_BODY_CLASS));
};
syncEReaderImmersiveState();
const observer = new MutationObserver(syncEReaderImmersiveState);
observer.observe(body, { attributes: true, attributeFilter: ['class'] });
return () => {
observer.disconnect();
};
}, []);
useEffect(() => {
const mediaQuery = window.matchMedia('(max-width: 768px)');
const updateViewport = () => {
setIsMobileViewport(getIsMobileViewport());
};
updateViewport();
mediaQuery.addEventListener('change', updateViewport);
return () => {
mediaQuery.removeEventListener('change', updateViewport);
};
}, []);
useEffect(() => {
const mediaQuery = window.matchMedia(routeState.topMenu === 'chat' ? '(max-width: 1180px)' : '(max-width: 768px)');
const updateViewport = () => {
setIsSidebarOverlayViewport(getIsSidebarOverlayViewport(routeState.topMenu));
};
updateViewport();
mediaQuery.addEventListener('change', updateViewport);
return () => {
mediaQuery.removeEventListener('change', updateViewport);
};
}, [routeState.topMenu]);
useEffect(() => {
setSidebarCollapsed(resolveSidebarCollapsedForViewport(isSidebarOverlayViewport));
}, [isSidebarOverlayViewport, routeState.topMenu]);
useEffect(() => {
setSidebarOpenKeys(resolveSidebarOpenKeys(routeState.topMenu, hasAccess, routeState.planMenu, routeState.chatMenu));
}, [hasAccess, routeState.chatMenu, routeState.planMenu, routeState.topMenu]);
useEffect(() => {
if (docFolders.length > 0 && routeState.topMenu === 'docs' && !docFolders.includes(routeState.docsMenu)) {
navigateWithinApp(buildDocsPath(docFolders[0]), { replace: true });
}
}, [docFolders, navigate, location.search, previewRuntime, routeState.docsMenu, routeState.topMenu]);
useEffect(() => {
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(routeState.playMenu);
if (savedLayoutId && savedLayoutsReady && !savedLayouts.some((record) => record.id === savedLayoutId)) {
navigateWithinApp(buildPlayPath('layout'), { replace: true });
}
}, [location.search, navigate, previewRuntime, routeState.playMenu, savedLayouts, savedLayoutsReady]);
useEffect(() => {
if (!isRestrictedTopMenu(routeState.topMenu, hasAccess)) {
return;
}
navigate(`${buildDocsPath(DOCS_DEFAULT_FOLDER)}${location.search}`, { replace: true });
}, [hasAccess, location.search, navigate, routeState.topMenu]);
useEffect(() => {
if (routeState.topMenu !== 'plans') {
setActivePlanQuickFilter(null);
return;
}
if (routeState.planMenu === 'release') {
setActivePlanQuickFilter('release-pending-main');
return;
}
setActivePlanQuickFilter((current) => (current === 'working' || current === 'automation-failed' ? current : null));
}, [routeState.planMenu, routeState.topMenu]);
const gestureLayer = useMemo(
() => ({
id: 'main-layout',
enabled:
!isEReaderImmersiveActive &&
!(isMobileViewport && routeState.topMenu === 'docs' && routeState.docsMenu === DOCS_DEFAULT_FOLDER),
gestures: [
{
id: 'mobile-top-right-pull-alert',
activeStates: ['anyway'],
mobileOnly: true,
trigger: 'pull-down-top-right' as const,
onTrigger: () => {
openSearch();
},
},
],
}),
[isEReaderImmersiveActive, isMobileViewport, openSearch, routeState.docsMenu, routeState.topMenu],
);
useGesturePageState('anyway');
useGestureLayer(gestureLayer);
useEffect(() => {
const handleWindowKeyDown = (event: KeyboardEvent) => {
if (event.repeat || isTypingTarget(event.target) || isEReaderImmersiveActive) {
return;
}
if (matchesShortcut(event, appConfig.gestureShortcuts.openSearch)) {
event.preventDefault();
openSearch();
}
};
window.addEventListener('keydown', handleWindowKeyDown);
return () => {
window.removeEventListener('keydown', handleWindowKeyDown);
};
}, [
appConfig.gestureShortcuts.openSearch,
isEReaderImmersiveActive,
openSearch,
]);
const selectedDocs = useMemo(
() => docsDocuments.filter((document) => document.folder === routeState.docsMenu),
[docsDocuments, routeState.docsMenu],
);
const searchOptions = useMemo(
() =>
buildSearchOptions({
componentSamples,
widgetSamples,
docFolders,
docsDocuments,
savedLayouts,
hasAccess,
navigateTo: (path) => {
navigateWithinApp(path);
},
setFocusedComponentId,
requestPlanQuickFilter: (filter) => {
setActivePlanQuickFilter(filter);
setPlanQuickFilterRequestKey((previous) => previous + 1);
},
}),
[
componentSamples,
docFolders,
docsDocuments,
hasAccess,
location.search,
navigate,
previewRuntime,
savedLayouts,
setFocusedComponentId,
widgetSamples,
],
);
useEffect(() => {
setSearchOptions(searchOptions);
}, [searchOptions, setSearchOptions]);
useEffect(() => {
setCurrentPage(
resolveCurrentPageDescriptor({
topMenu: routeState.topMenu,
docsMenu: routeState.docsMenu,
apiMenu: routeState.apiMenu,
planMenu: routeState.planMenu,
chatMenu: routeState.chatMenu,
playMenu: routeState.playMenu,
savedLayouts,
}),
);
}, [routeState, savedLayouts, setCurrentPage]);
const currentDocsFolder = docFolders.includes(routeState.docsMenu) ? routeState.docsMenu : docFolders[0] ?? DOCS_DEFAULT_FOLDER;
const sidebarIntro = renderSidebarIntro(routeState.topMenu);
const apiMenuItems = useMemo(() => buildApiMenuItems(), []);
const docsMenuItems = useMemo(() => buildDocsMenuItems(docFolders), [docFolders]);
const planMenuItems = useMemo(() => buildPlanMenuItems(hasAccess), [hasAccess]);
const chatMenuItems = useMemo(() => buildChatMenuItems(hasAccess, chatUnreadCount), [chatUnreadCount, hasAccess]);
const playMenuItems = useMemo(() => buildPlayMenuItems(savedLayouts), [savedLayouts]);
const activePlayAppId = routeState.topMenu === 'play' && routeState.playMenu === 'apps'
? searchParams.get('app')?.trim() ?? ''
: '';
const isPlayAppFullscreen = activePlayAppId.length > 0;
const initialSelectedPlanId = Number(searchParams.get('planId'));
const initialSelectedWorkId = searchParams.get('workId');
return (
<MainLayoutContextProvider
value={{
topMenu: routeState.topMenu,
selectedDocsMenu: routeState.docsMenu,
selectedApiMenu: routeState.apiMenu,
selectedPlanMenu: routeState.planMenu,
selectedChatMenu: routeState.chatMenu,
selectedPlayMenu: routeState.playMenu,
activePlanQuickFilter,
planQuickFilterRequestKey,
initialSelectedPlanId: Number.isFinite(initialSelectedPlanId) ? initialSelectedPlanId : null,
initialSelectedWorkId,
selectedDocs,
docsDocuments,
componentSampleEntries,
widgetSampleEntries,
componentSamples,
widgetSamples,
savedLayouts,
savedLayoutsReady,
setSavedLayouts,
searchOptions,
}}
>
<Layout className={`app-shell app-shell--docs-api${previewRuntime ? ' app-shell--preview-runtime' : ''}`}>
{routeState.topMenu === 'chat' || isPlayAppFullscreen ? null : <ChatRuntimeBridgeV2 />}
{contentExpanded || isPlayAppFullscreen ? null : (
<MainHeader
activeTopMenu={routeState.topMenu}
sidebarCollapsed={sidebarCollapsed}
contentExpanded={contentExpanded}
isMobileViewport={isMobileViewport}
onToggleSidebar={() => {
setSidebarCollapsed((previous) => !previous);
}}
onToggleContentExpanded={() => {
setContentExpanded((previous) => !previous);
}}
onChangeTopMenu={(menu) => {
navigateWithinApp(resolveTopMenuPath(menu, currentDocsFolder));
setSidebarCollapsed(resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport(menu)));
}}
onOpenSearch={() => {
openSearch();
}}
onOpenPlanQuickFilter={(filter) => {
const targetPlanMenu = resolvePlanQuickFilterMenu(filter);
setActivePlanQuickFilter(filter);
setPlanQuickFilterRequestKey((previous) => previous + 1);
navigateWithinApp(buildPlansPath(targetPlanMenu));
setSidebarCollapsed(resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport('plans')));
scrollToElement(PLAN_MENU_ANCHOR_IDS[targetPlanMenu] ?? 'plan-menu-all');
}}
/>
)}
<Layout className="app-shell__body">
{contentExpanded || isPlayAppFullscreen || (isSidebarOverlayViewport && sidebarCollapsed) ? null : (
<MainSidebar
activeTopMenu={routeState.topMenu}
hasAccess={hasAccess}
sidebarCollapsed={sidebarCollapsed}
isMobileViewport={isMobileViewport}
openKeys={sidebarOpenKeys}
apiMenuItems={apiMenuItems}
docsMenuItems={docsMenuItems}
planMenuItems={planMenuItems}
chatMenuItems={chatMenuItems}
playMenuItems={playMenuItems}
selectedApiMenu={routeState.apiMenu}
selectedDocsMenu={routeState.docsMenu}
selectedPlanMenu={routeState.planMenu}
selectedChatMenu={routeState.chatMenu}
selectedPlayMenu={routeState.playMenu}
onOpenKeysChange={setSidebarOpenKeys}
onSelectApiMenu={(key) => {
navigateWithinApp(buildApisPath(key as ApiSectionKey));
if (isSidebarOverlayViewport) {
setSidebarCollapsed(true);
}
}}
onSelectDocsMenu={(key) => {
navigateWithinApp(buildDocsPath(key));
if (isSidebarOverlayViewport) {
setSidebarCollapsed(true);
}
}}
onSelectPlanMenu={(key) => {
setActivePlanQuickFilter(key === 'release' ? 'release-pending-main' : null);
setPlanQuickFilterRequestKey((previous) => previous + 1);
navigateWithinApp(buildPlansPath(key));
if (isSidebarOverlayViewport) {
setSidebarCollapsed(true);
}
}}
onSelectChatMenu={(key) => {
navigateWithinApp(buildChatPath(key), { resetSearch: true });
if (isSidebarOverlayViewport) {
setSidebarCollapsed(true);
}
}}
onSelectPlayMenu={(key) => {
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(key);
navigateWithinApp(savedLayoutId ? buildSavedLayoutPath(savedLayoutId) : buildPlayPath(key));
if (isSidebarOverlayViewport) {
setSidebarCollapsed(true);
}
}}
introColor={sidebarIntro.color}
introTag={sidebarIntro.tag}
introDescription={sidebarIntro.description}
/>
)}
<MainContent
contentExpanded={contentExpanded}
sidebarOverlayActive={!previewRuntime && isSidebarOverlayViewport && !sidebarCollapsed}
disableWindowLayer={previewRuntime}
onToggleContentExpanded={() => setContentExpanded((previous) => !previous)}
>
<Outlet />
</MainContent>
</Layout>
</Layout>
</MainLayoutContextProvider>
);
}