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 && (