diff --git a/src/components/refactored/shared/contexts/system-ui-context/SystemUIContext.ts b/src/components/refactored/shared/contexts/system-ui-context/SystemUIContext.ts index 63c68c29..149972df 100644 --- a/src/components/refactored/shared/contexts/system-ui-context/SystemUIContext.ts +++ b/src/components/refactored/shared/contexts/system-ui-context/SystemUIContext.ts @@ -4,6 +4,8 @@ import type { Dispatch, SetStateAction } from 'react'; export type SystemUIContextValue = { sidebarIsCollapsed: boolean; setSidebarIsCollapsed: Dispatch>; + isChatInputFocused: boolean; + setIsChatInputFocused: Dispatch>; }; export const SystemUIContext = createContext(null); diff --git a/src/components/refactored/shared/contexts/system-ui-context/SystemUIProvider.tsx b/src/components/refactored/shared/contexts/system-ui-context/SystemUIProvider.tsx index e479f303..2cfc3ae8 100644 --- a/src/components/refactored/shared/contexts/system-ui-context/SystemUIProvider.tsx +++ b/src/components/refactored/shared/contexts/system-ui-context/SystemUIProvider.tsx @@ -3,13 +3,16 @@ import { SystemUIContext, type SystemUIContextValue } from '@/components/refacto export function SystemUIProvider({ children }: { children: ReactNode }) { const [sidebarIsCollapsed, setSidebarIsCollapsed] = useState(false); + const [isChatInputFocused, setIsChatInputFocused] = useState(false); const value = useMemo( () => ({ sidebarIsCollapsed, setSidebarIsCollapsed, + isChatInputFocused, + setIsChatInputFocused, }), - [sidebarIsCollapsed], + [isChatInputFocused, sidebarIsCollapsed], ); return ( diff --git a/src/components/refactored/shared/layout/MobileNav.tsx b/src/components/refactored/shared/layout/MobileNav.tsx new file mode 100644 index 00000000..c8f24ad7 --- /dev/null +++ b/src/components/refactored/shared/layout/MobileNav.tsx @@ -0,0 +1,227 @@ +import { useState, useRef, useEffect, useMemo } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + MessageSquare, + Folder, + Terminal, + GitBranch, + ClipboardCheck, + Ellipsis, + Puzzle, + Box, + Database, + Globe, + Wrench, + Zap, + BarChart3, + type LucideIcon, +} from 'lucide-react'; +import { useTasksSettings } from '@/contexts/TasksSettingsContext'; +import { usePlugins } from '@/contexts/PluginsContext'; +import { useDeviceSettings } from '@/hooks/useDeviceSettings'; +import { useSystemUI } from '@/components/refactored/shared/contexts/system-ui-context/useSystemUI'; +import type { AppTab } from '@/types/app'; + +const PLUGIN_ICON_MAP: Record = { + Puzzle, Box, Database, Globe, Terminal, Wrench, Zap, BarChart3, Folder, MessageSquare, GitBranch, +}; + +type CoreTabId = Exclude; +type CoreNavItem = { + id: CoreTabId; + icon: LucideIcon; + label: string; +}; + +type MobileNavRouteParams = { + workspaceId?: string; + sessionId?: string; + tab?: string; +}; + +const decodeValue = (value?: string): string => { + if (!value) { + return ''; + } + + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + +export function MobileNav() { + const navigate = useNavigate(); + const { t } = useTranslation(['common', 'settings']); + const { isMobile } = useDeviceSettings({ trackPWA: false }); + const { isChatInputFocused } = useSystemUI(); + const { workspaceId, sessionId, tab } = useParams(); + const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings(); + const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled); + const { plugins } = usePlugins(); + const [moreOpen, setMoreOpen] = useState(false); + const moreRef = useRef(null); + + const enabledPlugins = plugins.filter((plugin) => plugin.enabled); + const hasPlugins = enabledPlugins.length > 0; + const activeTab = useMemo(() => { + const routeTab = decodeValue(tab); + return (routeTab || 'chat') as AppTab; + }, [tab]); + const isPluginActive = activeTab.startsWith('plugin:'); + + useEffect(() => { + if (!moreOpen) { + return; + } + + const handleTap = (event: PointerEvent) => { + const target = event.target; + if (moreRef.current && target instanceof Node && !moreRef.current.contains(target)) { + setMoreOpen(false); + } + }; + + document.addEventListener('pointerdown', handleTap); + return () => document.removeEventListener('pointerdown', handleTap); + }, [moreOpen]); + + const navigateToTab = (nextTab: AppTab) => { + if (!workspaceId) { + return; + } + + const encodedWorkspaceId = encodeURIComponent(decodeValue(workspaceId)); + const encodedTab = encodeURIComponent(nextTab); + const decodedSessionId = decodeValue(sessionId); + + if (decodedSessionId) { + navigate( + `/workspaces/${encodedWorkspaceId}/sessions/${encodeURIComponent(decodedSessionId)}/${encodedTab}`, + ); + return; + } + + navigate(`/workspaces/${encodedWorkspaceId}/${encodedTab}`); + }; + + const selectPlugin = (name: string) => { + const pluginTab = `plugin:${name}` as AppTab; + navigateToTab(pluginTab); + setMoreOpen(false); + }; + + const baseCoreItems: CoreNavItem[] = [ + { id: 'chat', icon: MessageSquare, label: 'Chat' }, + { id: 'shell', icon: Terminal, label: 'Shell' }, + { id: 'files', icon: Folder, label: 'Files' }, + { id: 'git', icon: GitBranch, label: 'Git' }, + ]; + const coreItems: CoreNavItem[] = shouldShowTasksTab + ? [...baseCoreItems, { id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }] + : baseCoreItems; + + if (!isMobile) { + return null; + } + + return ( +
+
+
+ {coreItems.map((item) => { + const Icon = item.icon; + const isActive = activeTab === item.id; + + return ( + + ); + })} + + {/* "More" button - only shown when there are enabled plugins */} + {hasPlugins && ( +
+ + + {/* Popover menu */} + {moreOpen && ( +
+ {enabledPlugins.map((plugin) => { + const Icon = PLUGIN_ICON_MAP[plugin.icon] || Puzzle; + const isActive = activeTab === `plugin:${plugin.name}`; + + return ( + + ); + })} +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/src/components/refactored/shared/layout/RootLayout.tsx b/src/components/refactored/shared/layout/RootLayout.tsx index 2e516b7e..8739248e 100644 --- a/src/components/refactored/shared/layout/RootLayout.tsx +++ b/src/components/refactored/shared/layout/RootLayout.tsx @@ -1,17 +1,22 @@ import { Outlet } from 'react-router-dom'; import { Sidebar } from '@/components/refactored/sidebar/view/Sidebar'; import { MainHeading } from '@/components/refactored/shared/layout/MainHeading'; +import { MobileNav } from '@/components/refactored/shared/layout/MobileNav'; +import { useDeviceSettings } from '@/hooks/useDeviceSettings'; export function RootLayout() { + const { isMobile } = useDeviceSettings({ trackPWA: false }); + return (
-
+
+
); }