From 582508424b144f687700e1d7b6aaa81a8f66f3bd Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Tue, 7 Apr 2026 19:38:25 +0300 Subject: [PATCH] refactor: setup tab switchers with route changers --- src/App.tsx | 168 ++++++++++++---- .../system-ui-context/SystemUIContext.ts | 9 + .../system-ui-context/SystemUIProvider.tsx | 20 ++ .../contexts/system-ui-context/useSystemUI.ts | 11 ++ .../refactored/shared/layout/MainHeading.tsx | 187 ++++++++++++++++++ .../shared/layout/MainHeadingTabSwitcher.tsx | 98 +++++++++ .../refactored/shared/layout/RootLayout.tsx | 12 +- .../sidebar/hooks/useSidebarSettings.ts | 18 -- .../refactored/sidebar/view/Sidebar.tsx | 12 +- 9 files changed, 474 insertions(+), 61 deletions(-) create mode 100644 src/components/refactored/shared/contexts/system-ui-context/SystemUIContext.ts create mode 100644 src/components/refactored/shared/contexts/system-ui-context/SystemUIProvider.tsx create mode 100644 src/components/refactored/shared/contexts/system-ui-context/useSystemUI.ts create mode 100644 src/components/refactored/shared/layout/MainHeading.tsx create mode 100644 src/components/refactored/shared/layout/MainHeadingTabSwitcher.tsx delete mode 100644 src/components/refactored/sidebar/hooks/useSidebarSettings.ts diff --git a/src/App.tsx b/src/App.tsx index 73cf7e7f..c11602c1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,55 +1,153 @@ -import { RouterProvider, createBrowserRouter } from 'react-router-dom'; import { I18nextProvider } from 'react-i18next'; -import { ThemeProvider } from './contexts/ThemeContext'; +import { + Navigate, + Outlet, + RouterProvider, + createBrowserRouter, + useParams, +} from 'react-router-dom'; import { AuthProvider, ProtectedRoute } from './components/auth'; +import { ThemeProvider } from './contexts/ThemeContext'; +import { PluginsProvider } from './contexts/PluginsContext'; +import { TaskMasterProvider } from './contexts/TaskMasterContext'; +import { TasksSettingsProvider } from './contexts/TasksSettingsContext'; +import { WebSocketProvider } from './contexts/WebSocketContext'; import i18n from './i18n/config.js'; -import { RootLayout } from '@/components/refactored/shared/RootLayout'; +import { SystemUIProvider } from '@/components/refactored/shared/contexts/system-ui-context/SystemUIProvider'; +import { RootLayout } from '@/components/refactored/shared/layout/RootLayout'; -// Mock page components -const Home = () =>

Home Page

Select a session or create a new project.

; -const WorkspaceContent = () =>

Workspace View

Select a session or start a new one.

; -const SessionContent = () =>

Session View

Chat interface goes here.

; +const isValidRouteTab = (value: string | undefined): boolean => { + if (!value) { + return false; + } -const router = createBrowserRouter([ - { - path: "/", - element: , // The layout wraps all children - children: [ - { - path: "/", - element: , - }, - { - path: "/session/:sessionId", - element: , - }, - { - path: "/sessions/:sessionId", - element: , - }, - { - path: "/workspace/:workspaceId", - element: , - }, - ], - }, -]); + const normalizedValue = decodeURIComponent(value); + return ( + normalizedValue === 'chat' || + normalizedValue === 'shell' || + normalizedValue === 'files' || + normalizedValue === 'git' || + normalizedValue === 'tasks' || + normalizedValue === 'preview' || + normalizedValue.startsWith('plugin:') + ); +}; + +function NoWorkspaceRoute() { + return ( +
+
+

Choose Your Project

+

+ This is the root route (`/`) empty state. Select a workspace from the sidebar to continue. +

+
+
+ ); +} + +function WorkspaceLayout() { + const { workspaceId } = useParams<{ workspaceId: string }>(); + if (!workspaceId) { + return ; + } + + return ( +
+ +
+ ); +} + +function WorkspaceTabRoute() { + const { workspaceId, sessionId, tab } = useParams<{ + workspaceId: string; + sessionId?: string; + tab: string; + }>(); + + if (!workspaceId) { + return ; + } + + if (!isValidRouteTab(tab)) { + return ; + } + + const decodedWorkspaceId = decodeURIComponent(workspaceId); + const decodedSessionId = sessionId ? decodeURIComponent(sessionId) : null; + const decodedTab = tab ? decodeURIComponent(tab) : 'chat'; + + return ( +
+
+

{decodedTab} view

+

+ Workspace: {decodedWorkspaceId} +

+

+ Session:{' '} + + {decodedSessionId || 'none (workspace-level tab)'} + +

+
+
+ ); +} + +const router = createBrowserRouter( + [ + { + path: '/', + element: , + children: [ + { index: true, element: }, + { + path: 'workspaces/:workspaceId', + element: , + children: [ + { index: true, element: }, + { path: ':tab', element: }, + { + path: 'sessions/:sessionId', + children: [ + { index: true, element: }, + { path: ':tab', element: }, + ], + }, + ], + }, + ], + }, + ], + { basename: window.__ROUTER_BASENAME__ || '' }, +); export default function App() { return ( - - - + + + + + + + + + + + + + ); } - // import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; // import { I18nextProvider } from 'react-i18next'; // import { ThemeProvider } from './contexts/ThemeContext'; diff --git a/src/components/refactored/shared/contexts/system-ui-context/SystemUIContext.ts b/src/components/refactored/shared/contexts/system-ui-context/SystemUIContext.ts new file mode 100644 index 00000000..63c68c29 --- /dev/null +++ b/src/components/refactored/shared/contexts/system-ui-context/SystemUIContext.ts @@ -0,0 +1,9 @@ +import { createContext } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; + +export type SystemUIContextValue = { + sidebarIsCollapsed: boolean; + setSidebarIsCollapsed: 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 new file mode 100644 index 00000000..e479f303 --- /dev/null +++ b/src/components/refactored/shared/contexts/system-ui-context/SystemUIProvider.tsx @@ -0,0 +1,20 @@ +import { useMemo, useState, type ReactNode } from 'react'; +import { SystemUIContext, type SystemUIContextValue } from '@/components/refactored/shared/contexts/system-ui-context/SystemUIContext'; + +export function SystemUIProvider({ children }: { children: ReactNode }) { + const [sidebarIsCollapsed, setSidebarIsCollapsed] = useState(false); + + const value = useMemo( + () => ({ + sidebarIsCollapsed, + setSidebarIsCollapsed, + }), + [sidebarIsCollapsed], + ); + + return ( + + {children} + + ); +} diff --git a/src/components/refactored/shared/contexts/system-ui-context/useSystemUI.ts b/src/components/refactored/shared/contexts/system-ui-context/useSystemUI.ts new file mode 100644 index 00000000..d37d82ae --- /dev/null +++ b/src/components/refactored/shared/contexts/system-ui-context/useSystemUI.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react'; +import { SystemUIContext } from '@/components/refactored/shared/contexts/system-ui-context/SystemUIContext'; + +export const useSystemUI = () => { + const context = useContext(SystemUIContext); + if (!context) { + throw new Error('useSystemUI must be used within SystemUIProvider'); + } + + return context; +}; diff --git a/src/components/refactored/shared/layout/MainHeading.tsx b/src/components/refactored/shared/layout/MainHeading.tsx new file mode 100644 index 00000000..87b520bb --- /dev/null +++ b/src/components/refactored/shared/layout/MainHeading.tsx @@ -0,0 +1,187 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import type { AppTab } from '@/types/app'; +import { usePlugins } from '@/contexts/PluginsContext'; +import { useDeviceSettings } from '@/hooks/useDeviceSettings'; +import { useSystemUI } from '@/components/refactored/shared/contexts/system-ui-context/useSystemUI'; +import { MainHeadingTabSwitcher } from '@/components/refactored/shared/layout/MainHeadingTabSwitcher'; + +type MainHeadingRouteParams = { + workspaceId?: string; + sessionId?: string; + tab?: string; +}; + +const decodeValue = (value?: string): string => { + if (!value) { + return ''; + } + + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + +const getTabTitle = (tab: AppTab, pluginDisplayName: string | undefined, t: (key: string) => string) => { + if (tab.startsWith('plugin:') && pluginDisplayName) { + return pluginDisplayName; + } + + if (tab === 'files') { + return t('mainContent.projectFiles'); + } + + if (tab === 'git') { + return t('tabs.git'); + } + + if (tab === 'tasks') { + return 'TaskMaster'; + } + + if (tab === 'shell') { + return t('tabs.shell'); + } + + return t('tabs.chat'); +}; + +export function MainHeading() { + const navigate = useNavigate(); + const { t } = useTranslation(['common', 'sidebar']); + const { plugins } = usePlugins(); + const { isMobile } = useDeviceSettings({ trackPWA: false }); + const { sidebarIsCollapsed, setSidebarIsCollapsed } = useSystemUI(); + const { workspaceId, sessionId, tab } = useParams(); + + const scrollRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + const decodedWorkspaceId = useMemo(() => decodeValue(workspaceId), [workspaceId]); + const decodedSessionId = useMemo(() => decodeValue(sessionId), [sessionId]); + const activeTab = useMemo(() => { + const routeTab = decodeValue(tab); + return (routeTab || 'chat') as AppTab; + }, [tab]); + + const pluginDisplayName = useMemo( + () => + activeTab.startsWith('plugin:') + ? plugins.find((plugin) => plugin.name === activeTab.replace('plugin:', ''))?.displayName + : undefined, + [activeTab, plugins], + ); + + const title = useMemo(() => { + if (activeTab === 'chat' && decodedSessionId) { + return decodedSessionId; + } + + if (activeTab === 'chat') { + return t('mainContent.newSession'); + } + + return getTabTitle(activeTab, pluginDisplayName, t); + }, [activeTab, decodedSessionId, pluginDisplayName, t]); + + const updateScrollState = useCallback(() => { + const element = scrollRef.current; + if (!element) { + return; + } + + setCanScrollLeft(element.scrollLeft > 2); + setCanScrollRight(element.scrollLeft < element.scrollWidth - element.clientWidth - 2); + }, []); + + useEffect(() => { + const element = scrollRef.current; + if (!element || isMobile) { + return; + } + + updateScrollState(); + const observer = new ResizeObserver(updateScrollState); + observer.observe(element); + + return () => observer.disconnect(); + }, [isMobile, updateScrollState]); + + if (!workspaceId) { + return null; + } + + const handleTabSelect = (nextTab: AppTab) => { + // Preserve workspace/session context while switching only the active tab path segment. + const encodedWorkspaceId = encodeURIComponent(decodedWorkspaceId); + const encodedTab = encodeURIComponent(nextTab); + + if (decodedSessionId) { + navigate( + `/workspaces/${encodedWorkspaceId}/sessions/${encodeURIComponent(decodedSessionId)}/${encodedTab}`, + ); + return; + } + + navigate(`/workspaces/${encodedWorkspaceId}/${encodedTab}`); + }; + + return ( +
+
+
+ {isMobile && ( + + )} + +
+
+

+ {title} +

+
+ {decodedWorkspaceId} +
+
+
+
+ + {!isMobile && ( +
+ {canScrollLeft && ( +
+ )} +
+ +
+ {canScrollRight && ( +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/refactored/shared/layout/MainHeadingTabSwitcher.tsx b/src/components/refactored/shared/layout/MainHeadingTabSwitcher.tsx new file mode 100644 index 00000000..0ddd2bc0 --- /dev/null +++ b/src/components/refactored/shared/layout/MainHeadingTabSwitcher.tsx @@ -0,0 +1,98 @@ +import { ClipboardCheck, Folder, GitBranch, MessageSquare, Terminal, type LucideIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { usePlugins } from '@/contexts/PluginsContext'; +import { useTasksSettings } from '@/contexts/TasksSettingsContext'; +import { Pill, PillBar, Tooltip } from '@/shared/view/ui'; +import type { AppTab } from '@/types/app'; +import PluginIcon from '@/components/plugins/view/PluginIcon'; + +type TasksSettingsContextValue = { + tasksEnabled: boolean; + isTaskMasterInstalled: boolean | null; +}; + +type BuiltInTab = { + kind: 'builtin'; + id: AppTab; + labelKey: string; + icon: LucideIcon; +}; + +type PluginTab = { + kind: 'plugin'; + id: AppTab; + label: string; + pluginName: string; + iconFile: string; +}; + +type TabDefinition = BuiltInTab | PluginTab; + +type MainHeadingTabSwitcherProps = { + activeTab: AppTab; + onTabSelect: (tabId: AppTab) => void; +}; + +const BASE_TABS: BuiltInTab[] = [ + { kind: 'builtin', id: 'chat', labelKey: 'tabs.chat', icon: MessageSquare }, + { kind: 'builtin', id: 'shell', labelKey: 'tabs.shell', icon: Terminal }, + { kind: 'builtin', id: 'files', labelKey: 'tabs.files', icon: Folder }, + { kind: 'builtin', id: 'git', labelKey: 'tabs.git', icon: GitBranch }, +]; + +const TASKS_TAB: BuiltInTab = { + kind: 'builtin', + id: 'tasks', + labelKey: 'tabs.tasks', + icon: ClipboardCheck, +}; + +export function MainHeadingTabSwitcher({ activeTab, onTabSelect }: MainHeadingTabSwitcherProps) { + const { t } = useTranslation('common'); + const { plugins } = usePlugins(); + const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue; + const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled); + + const builtInTabs: BuiltInTab[] = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS; + const pluginTabs: PluginTab[] = plugins + .filter((plugin) => plugin.enabled) + .map((plugin) => ({ + kind: 'plugin', + id: `plugin:${plugin.name}` as AppTab, + label: plugin.displayName, + pluginName: plugin.name, + iconFile: plugin.icon, + })); + + const tabs: TabDefinition[] = [...builtInTabs, ...pluginTabs]; + + return ( + + {tabs.map((tab) => { + const isActive = tab.id === activeTab; + const displayLabel = tab.kind === 'builtin' ? t(tab.labelKey) : tab.label; + + return ( + + onTabSelect(tab.id)} + className="px-2.5 py-[5px]" + > + {tab.kind === 'builtin' ? ( + + ) : ( + + )} + {displayLabel} + + + ); + })} + + ); +} diff --git a/src/components/refactored/shared/layout/RootLayout.tsx b/src/components/refactored/shared/layout/RootLayout.tsx index 374178d5..2e516b7e 100644 --- a/src/components/refactored/shared/layout/RootLayout.tsx +++ b/src/components/refactored/shared/layout/RootLayout.tsx @@ -1,15 +1,17 @@ - import { Outlet } from 'react-router-dom'; import { Sidebar } from '@/components/refactored/sidebar/view/Sidebar'; - +import { MainHeading } from '@/components/refactored/shared/layout/MainHeading'; export function RootLayout() { return (
-
- -
+
+ +
+ +
+
); } diff --git a/src/components/refactored/sidebar/hooks/useSidebarSettings.ts b/src/components/refactored/sidebar/hooks/useSidebarSettings.ts deleted file mode 100644 index 5e5d1fe0..00000000 --- a/src/components/refactored/sidebar/hooks/useSidebarSettings.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useState } from 'react'; - -/** - * Hook layer (The Manager) - * Manages the layout states for the sidebar, such as collapse/open. - */ -export const useSidebarSettings = () => { - const [isCollapsed, setIsCollapsed] = useState(false); - - const toggleCollapse = () => setIsCollapsed((prev) => !prev); - const setCollapsed = (value: boolean) => setIsCollapsed(value); - - return { - isCollapsed, - toggleCollapse, - setCollapsed, - }; -}; diff --git a/src/components/refactored/sidebar/view/Sidebar.tsx b/src/components/refactored/sidebar/view/Sidebar.tsx index 171089e9..b003443f 100644 --- a/src/components/refactored/sidebar/view/Sidebar.tsx +++ b/src/components/refactored/sidebar/view/Sidebar.tsx @@ -1,18 +1,23 @@ import SidebarFooter from './SidebarFooter'; -import { useSidebarSettings } from '@/components/refactored/sidebar/hooks/useSidebarSettings'; import { useSidebarModals } from '@/components/refactored/sidebar/hooks/useSidebarModals'; import { useWorkspaces } from '@/components/refactored/sidebar/hooks/useWorkspaces'; import SidebarHeader from '@/components/refactored/sidebar/view/SidebarHeader'; import { SidebarDeleteModals } from '@/components/refactored/sidebar/view/SidebarDeleteModals'; import { SidebarWorkspaceList } from '@/components/refactored/sidebar/view/SidebarWorkspaceList'; import { SidebarCollapsed } from '@/components/refactored/sidebar/view/SidebarCollapsed'; +import { useSystemUI } from '@/components/refactored/shared/contexts/system-ui-context/useSystemUI'; +import { useDeviceSettings } from '@/hooks/useDeviceSettings'; import { cn } from '@/lib/utils'; import ProjectCreationWizard from '@/components/project-creation-wizard'; import VersionUpgradeModal from '@/components/version-upgrade/view'; import Settings from '@/components/settings/view/Settings'; export function Sidebar() { - const { isCollapsed, toggleCollapse, setCollapsed } = useSidebarSettings(); + const { isMobile } = useDeviceSettings({ trackPWA: false }); + const { sidebarIsCollapsed, setSidebarIsCollapsed } = useSystemUI(); + const isCollapsed = sidebarIsCollapsed; + const toggleCollapse = () => setSidebarIsCollapsed((previousValue) => !previousValue); + const setCollapsed = (value: boolean) => setSidebarIsCollapsed(value); const { workspaces, starredWorkspaces, @@ -148,7 +153,8 @@ export function Sidebar() { )} - {isCollapsed && ( + {/* Keep collapsed rail desktop-only; mobile uses the header hamburger to reopen. */} + {isCollapsed && !isMobile && (