diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index ba0feeda..cb9c3efa 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -12,7 +12,7 @@ import { useMenuActions } from '@/hooks/useMenuActions'; import { useMessageSync } from '@/hooks/useMessageSync'; import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap'; import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup'; -import { useSessionDeepLink } from '@/hooks/useSessionDeepLink'; +import { useRouter } from '@/hooks/useRouter'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { GitPollingProvider } from '@/hooks/useGitPolling'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -160,7 +160,7 @@ function App({ apis }: AppProps) { usePushVisibilityBeacon(); - useSessionDeepLink(); + useRouter(); useKeyboardShortcuts(); diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index 1d75b226..6f953a38 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { cn, getModifierLabel } from '@/lib/utils'; import { SIDEBAR_SECTIONS } from '@/constants/sidebar'; import type { SidebarSection } from '@/constants/sidebar'; +import { useUIStore } from '@/stores/useUIStore'; import { RiArrowDownSLine, RiArrowLeftSLine, RiCloseLine, RiFolderLine } from '@remixicon/react'; import { DropdownMenu, @@ -60,7 +61,25 @@ interface SettingsViewProps { export const SettingsView: React.FC = ({ onClose, forceMobile }) => { const deviceInfo = useDeviceInfo(); const isMobile = forceMobile ?? deviceInfo.isMobile; - const [activeTab, setActiveTab] = React.useState('settings'); + + // Sync activeTab with store's sidebarSection for routing support + const storeSidebarSection = useUIStore((state) => state.sidebarSection); + const setSidebarSection = useUIStore((state) => state.setSidebarSection); + + // Use store's sidebarSection as the source of truth, but filter to valid settings sections + const activeTab = React.useMemo(() => { + // If store has a valid settings section (not 'sessions'), use it + if (storeSidebarSection !== 'sessions') { + return storeSidebarSection; + } + // Default to 'settings' if store has 'sessions' + return 'settings'; + }, [storeSidebarSection]); + + // Update store when tab changes + const setActiveTab = React.useCallback((tab: SidebarSection) => { + setSidebarSection(tab); + }, [setSidebarSection]); const [selectedOpenChamberSection, setSelectedOpenChamberSection] = React.useState('visual'); // Mobile drill-down state: show page content instead of sidebar const [showMobilePageContent, setShowMobilePageContent] = React.useState(false); @@ -257,7 +276,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile setActiveTab(tab); // Reset mobile drill-down state when changing tabs setShowMobilePageContent(false); - }, []); + }, [setActiveTab]); // Handle mobile sidebar item selection (drill-down to page) const handleMobileSidebarClick = React.useCallback(() => { diff --git a/packages/ui/src/hooks/useRouter.ts b/packages/ui/src/hooks/useRouter.ts new file mode 100644 index 00000000..33a77422 --- /dev/null +++ b/packages/ui/src/hooks/useRouter.ts @@ -0,0 +1,348 @@ +import React from 'react'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { parseRoute, updateBrowserURL, hasRouteParams } from '@/lib/router'; +import type { RouteState, AppRouteState } from '@/lib/router'; +import type { SidebarSection } from '@/constants/sidebar'; +import type { MainTab } from '@/stores/useUIStore'; + +/** + * Check if running in VS Code webview context. + */ +function isVSCodeContext(): boolean { + if (typeof window === 'undefined') { + return false; + } + const win = window as { __VSCODE_CONFIG__?: unknown }; + return win.__VSCODE_CONFIG__ !== undefined; +} + +/** + * Hook that provides bidirectional URL routing for OpenChamber. + * + * On mount: + * - Parses URL parameters and applies them to app state + * - Sets up subscriptions to sync state changes back to URL + * - Listens for browser back/forward navigation + * + * Works in: + * - Web: Full bidirectional sync + * - Desktop (Tauri): Full bidirectional sync + * - VS Code: State-only (no URL updates, reads initial params) + */ +export function useRouter(): void { + const isVSCode = React.useMemo(() => isVSCodeContext(), []); + + // Track initialization to avoid duplicate applies + const initializedRef = React.useRef(false); + const isApplyingRouteRef = React.useRef(false); + + // Get store actions (stable references) + const setCurrentSession = useSessionStore((state) => state.setCurrentSession); + const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); + const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); + const setSidebarSection = useUIStore((state) => state.setSidebarSection); + const navigateToDiff = useUIStore((state) => state.navigateToDiff); + + /** + * Apply a parsed route state to the application stores. + */ + const applyRoute = React.useCallback( + async (route: RouteState) => { + if (isApplyingRouteRef.current) { + return; + } + + isApplyingRouteRef.current = true; + + try { + // 1. Apply session first (may trigger async operations) + if (route.sessionId) { + const currentSessionId = useSessionStore.getState().currentSessionId; + if (route.sessionId !== currentSessionId) { + await setCurrentSession(route.sessionId); + } + } + + // 2. Handle settings (takes precedence over tabs - it's a full-screen overlay) + if (route.settingsSection) { + setSidebarSection(route.settingsSection); + setSettingsDialogOpen(true); + // Don't process tab when settings is open + return; + } + + // Close settings if URL has no settings section + if (useUIStore.getState().isSettingsDialogOpen) { + setSettingsDialogOpen(false); + } + + // 3. Apply tab + if (route.tab) { + setActiveMainTab(route.tab); + } + + // 4. Apply diff file (only if going to diff tab) + if (route.diffFile && (route.tab === 'diff' || !route.tab)) { + navigateToDiff(route.diffFile); + } + } finally { + isApplyingRouteRef.current = false; + } + }, + [setCurrentSession, setActiveMainTab, setSettingsDialogOpen, setSidebarSection, navigateToDiff] + ); + + /** + * Get current app state for URL serialization. + */ + const getCurrentAppState = React.useCallback((): AppRouteState => { + const sessionState = useSessionStore.getState(); + const uiState = useUIStore.getState(); + + return { + sessionId: sessionState.currentSessionId, + tab: uiState.activeMainTab, + isSettingsOpen: uiState.isSettingsDialogOpen, + settingsSection: uiState.sidebarSection, + diffFile: uiState.pendingDiffFile, + }; + }, []); + + /** + * Sync current app state to URL. + */ + const syncURLFromState = React.useCallback( + (options: { replace?: boolean } = {}) => { + if (isVSCode || isApplyingRouteRef.current) { + return; + } + + const state = getCurrentAppState(); + updateBrowserURL(state, options); + }, + [isVSCode, getCurrentAppState] + ); + + // Initialize: parse URL and apply route on mount + React.useEffect(() => { + if (initializedRef.current) { + return; + } + initializedRef.current = true; + + // Only process if URL has route params + if (!hasRouteParams()) { + // No route params - just set up sync (URL will update when user navigates) + return; + } + + const route = parseRoute(); + + // Apply the initial route + const initializeRoute = async () => { + await applyRoute(route); + + // After applying, update URL to normalized form (use replaceState) + if (!isVSCode) { + syncURLFromState({ replace: true }); + } + }; + + void initializeRoute(); + }, [applyRoute, isVSCode, syncURLFromState]); + + // Subscribe to session changes + React.useEffect(() => { + if (isVSCode) { + return; + } + + let prevSessionId: string | null = useSessionStore.getState().currentSessionId; + + const unsubscribe = useSessionStore.subscribe((state) => { + const sessionId = state.currentSessionId; + + // Skip if no change or if we're currently applying a route + if (sessionId === prevSessionId || isApplyingRouteRef.current) { + return; + } + + prevSessionId = sessionId; + syncURLFromState(); + }); + + return unsubscribe; + }, [isVSCode, syncURLFromState]); + + // Subscribe to UI store changes (tab, settings) + React.useEffect(() => { + if (isVSCode) { + return; + } + + let prevTab: MainTab = useUIStore.getState().activeMainTab; + let prevSettingsOpen: boolean = useUIStore.getState().isSettingsDialogOpen; + let prevSettingsSection: SidebarSection = useUIStore.getState().sidebarSection; + let prevDiffFile: string | null = useUIStore.getState().pendingDiffFile; + + const unsubscribe = useUIStore.subscribe((state) => { + // Skip if we're currently applying a route + if (isApplyingRouteRef.current) { + return; + } + + const tabChanged = state.activeMainTab !== prevTab; + const settingsOpenChanged = state.isSettingsDialogOpen !== prevSettingsOpen; + const settingsSectionChanged = state.sidebarSection !== prevSettingsSection; + const diffFileChanged = state.pendingDiffFile !== prevDiffFile && state.activeMainTab === 'diff'; + + // Update tracking vars + prevTab = state.activeMainTab; + prevSettingsOpen = state.isSettingsDialogOpen; + prevSettingsSection = state.sidebarSection; + prevDiffFile = state.pendingDiffFile; + + // Only sync if something relevant changed + if (tabChanged || settingsOpenChanged || settingsSectionChanged || diffFileChanged) { + syncURLFromState(); + } + }); + + return unsubscribe; + }, [isVSCode, syncURLFromState]); + + // Listen for browser back/forward navigation + React.useEffect(() => { + if (typeof window === 'undefined' || isVSCode) { + return; + } + + const handlePopState = () => { + // Parse the new URL and apply it + const route = parseRoute(); + + // Check if this is a route with any params, or if we should restore defaults + if (hasRouteParams()) { + void applyRoute(route); + } else { + // URL has no route params - this might be a "back to home" navigation + // Close settings if open, keep current session + const uiState = useUIStore.getState(); + if (uiState.isSettingsDialogOpen) { + setSettingsDialogOpen(false); + } + // Reset to chat tab if not already there + if (uiState.activeMainTab !== 'chat') { + setActiveMainTab('chat'); + } + } + }; + + window.addEventListener('popstate', handlePopState); + + return () => { + window.removeEventListener('popstate', handlePopState); + }; + }, [applyRoute, isVSCode, setActiveMainTab, setSettingsDialogOpen]); +} + +/** + * Programmatically navigate to a route. + * Can be used from outside React components. + */ +export function navigateToRoute(route: Partial): void { + if (typeof window === 'undefined') { + return; + } + + // Check VS Code context + const win = window as { __VSCODE_CONFIG__?: unknown }; + if (win.__VSCODE_CONFIG__ !== undefined) { + // In VS Code, just apply state changes directly + if (route.sessionId) { + void useSessionStore.getState().setCurrentSession(route.sessionId); + } + if (route.settingsSection) { + useUIStore.getState().setSidebarSection(route.settingsSection); + useUIStore.getState().setSettingsDialogOpen(true); + } else if (route.tab) { + useUIStore.getState().setActiveMainTab(route.tab); + } + if (route.diffFile) { + useUIStore.getState().navigateToDiff(route.diffFile); + } + return; + } + + // Build URL and navigate + const params = new URLSearchParams(); + + if (route.sessionId) { + params.set('session', route.sessionId); + } + if (route.settingsSection) { + params.set('settings', route.settingsSection); + } else if (route.tab && route.tab !== 'chat') { + if (useUIStore.getState().isSettingsDialogOpen) { + useUIStore.getState().setSettingsDialogOpen(false); + } + params.set('tab', route.tab); + } + if (route.diffFile) { + params.set('file', route.diffFile); + } + + const search = params.toString(); + const url = search ? `${window.location.pathname}?${search}` : window.location.pathname; + + window.history.pushState({ route }, '', url); + + // Also apply to state + if (route.sessionId) { + void useSessionStore.getState().setCurrentSession(route.sessionId); + } + if (route.settingsSection) { + useUIStore.getState().setSidebarSection(route.settingsSection); + useUIStore.getState().setSettingsDialogOpen(true); + } else if (route.tab) { + useUIStore.getState().setActiveMainTab(route.tab); + } + if (route.diffFile) { + useUIStore.getState().navigateToDiff(route.diffFile); + } +} + +/** + * Get a shareable URL for the current state. + */ +export function getShareableURL(): string { + if (typeof window === 'undefined') { + return '/'; + } + + const sessionState = useSessionStore.getState(); + const uiState = useUIStore.getState(); + + const params = new URLSearchParams(); + + if (sessionState.currentSessionId) { + params.set('session', sessionState.currentSessionId); + } + + if (uiState.isSettingsDialogOpen) { + const settingsSection = uiState.sidebarSection === 'sessions' ? 'settings' : uiState.sidebarSection; + params.set('settings', settingsSection); + } else if (uiState.activeMainTab !== 'chat') { + params.set('tab', uiState.activeMainTab); + } + + if (uiState.activeMainTab === 'diff' && uiState.pendingDiffFile) { + params.set('file', uiState.pendingDiffFile); + } + + const search = params.toString(); + const base = `${window.location.origin}${window.location.pathname}`; + + return search ? `${base}?${search}` : base; +} diff --git a/packages/ui/src/hooks/useSessionDeepLink.ts b/packages/ui/src/hooks/useSessionDeepLink.ts deleted file mode 100644 index 6c9dca1a..00000000 --- a/packages/ui/src/hooks/useSessionDeepLink.ts +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import { useSessionStore } from '@/stores/useSessionStore'; -import { useUIStore } from '@/stores/useUIStore'; - -export const useSessionDeepLink = () => { - const setCurrentSession = useSessionStore((state) => state.setCurrentSession); - - React.useEffect(() => { - if (typeof window === 'undefined') { - return; - } - - let sessionId: string | null = null; - try { - const params = new URLSearchParams(window.location.search); - sessionId = params.get('session'); - } catch { - return; - } - - if (!sessionId || sessionId.trim().length === 0) { - return; - } - - const run = async () => { - try { - useUIStore.getState().setActiveMainTab('chat'); - await setCurrentSession(sessionId as string); - } finally { - try { - const url = new URL(window.location.href); - url.searchParams.delete('session'); - window.history.replaceState({}, '', url.toString()); - } catch { - // ignore - } - } - }; - - void run(); - }, [setCurrentSession]); -}; diff --git a/packages/ui/src/lib/router/index.ts b/packages/ui/src/lib/router/index.ts new file mode 100644 index 00000000..40571d88 --- /dev/null +++ b/packages/ui/src/lib/router/index.ts @@ -0,0 +1,31 @@ +/** + * Router module for URL-based navigation in OpenChamber. + * + * Provides bidirectional sync between URL query parameters and application state. + * Works across web, desktop (Tauri), and VS Code (state-only mode). + * + * URL Schema: + * - `?session=` - Navigate to specific session + * - `?tab=` - Active main tab + * - `?settings=
` - Open settings to specific section + * - `?file=` - Diff view with file selected + * + * Examples: + * - `/?session=abc123` - Open session abc123 + * - `/?tab=git` - Open git tab + * - `/?settings=providers` - Open settings to providers section + * - `/?tab=diff&file=src/main.ts` - Open diff view with file + */ + +export type { RouteState, RouterContext } from './types'; +export { VALID_TABS, VALID_SETTINGS_SECTIONS, ROUTE_PARAMS } from './types'; + +export { parseRoute, hasRouteParams } from './parseRoute'; + +export type { AppRouteState } from './serializeRoute'; +export { + serializeRoute, + buildURL, + routeMatchesURL, + updateBrowserURL, +} from './serializeRoute'; diff --git a/packages/ui/src/lib/router/parseRoute.ts b/packages/ui/src/lib/router/parseRoute.ts new file mode 100644 index 00000000..544af095 --- /dev/null +++ b/packages/ui/src/lib/router/parseRoute.ts @@ -0,0 +1,133 @@ +import type { SidebarSection } from '@/constants/sidebar'; +import type { MainTab } from '@/stores/useUIStore'; +import { + type RouteState, + VALID_TABS, + VALID_SETTINGS_SECTIONS, + ROUTE_PARAMS, +} from './types'; + +/** + * Parse the current URL search parameters into a RouteState. + * Returns null values for any parameter that is missing or invalid. + */ +export function parseRoute(searchParams?: URLSearchParams): RouteState { + const params = searchParams ?? getSearchParams(); + + return { + sessionId: parseSessionId(params), + tab: parseTab(params), + settingsSection: parseSettingsSection(params), + diffFile: parseDiffFile(params), + }; +} + +/** + * Safely get URLSearchParams from the current location. + */ +function getSearchParams(): URLSearchParams { + if (typeof window === 'undefined') { + return new URLSearchParams(); + } + + try { + return new URLSearchParams(window.location.search); + } catch { + return new URLSearchParams(); + } +} + +/** + * Parse session ID from URL parameters. + * Returns null if missing or empty. + */ +function parseSessionId(params: URLSearchParams): string | null { + const value = params.get(ROUTE_PARAMS.SESSION); + if (!value || value.trim().length === 0) { + return null; + } + return value.trim(); +} + +/** + * Parse main tab from URL parameters. + * Returns null if missing or invalid. + */ +function parseTab(params: URLSearchParams): MainTab | null { + const value = params.get(ROUTE_PARAMS.TAB); + if (!value) { + return null; + } + + const normalized = value.toLowerCase().trim() as MainTab; + if (VALID_TABS.includes(normalized)) { + return normalized; + } + + return null; +} + +/** + * Parse settings section from URL parameters. + * Returns null if missing or invalid. + */ +function parseSettingsSection(params: URLSearchParams): SidebarSection | null { + const value = params.get(ROUTE_PARAMS.SETTINGS); + if (!value) { + return null; + } + + const normalized = value.toLowerCase().trim(); + + // Check if it's a valid section + if ((VALID_SETTINGS_SECTIONS as readonly string[]).includes(normalized)) { + return normalized as SidebarSection; + } + + // Handle common aliases + if (normalized === 'openchamber' || normalized === 'general' || normalized === 'preferences') { + return 'settings'; + } + + return null; +} + +/** + * Parse diff file path from URL parameters. + * Returns null if missing or empty. + */ +function parseDiffFile(params: URLSearchParams): string | null { + const value = params.get(ROUTE_PARAMS.FILE); + if (!value || value.trim().length === 0) { + return null; + } + + // URL decode the file path + try { + return decodeURIComponent(value.trim()); + } catch { + // If decoding fails, return the raw value + return value.trim(); + } +} + +/** + * Check if the current URL has any route parameters. + */ +export function hasRouteParams(): boolean { + if (typeof window === 'undefined') { + return false; + } + + try { + const params = new URLSearchParams(window.location.search); + return ( + params.has(ROUTE_PARAMS.SESSION) || + params.has(ROUTE_PARAMS.TAB) || + params.has(ROUTE_PARAMS.SETTINGS) || + params.has(ROUTE_PARAMS.FILE) + ); + } catch { + return false; + } +} diff --git a/packages/ui/src/lib/router/serializeRoute.ts b/packages/ui/src/lib/router/serializeRoute.ts new file mode 100644 index 00000000..eee2a09f --- /dev/null +++ b/packages/ui/src/lib/router/serializeRoute.ts @@ -0,0 +1,149 @@ +import type { SidebarSection } from '@/constants/sidebar'; +import type { MainTab } from '@/stores/useUIStore'; +import { ROUTE_PARAMS } from './types'; + +/** + * Application state relevant for URL serialization. + */ +export interface AppRouteState { + sessionId: string | null; + tab: MainTab; + isSettingsOpen: boolean; + settingsSection: SidebarSection; + diffFile: string | null; +} + +/** + * Default tab when none is specified. + */ +const DEFAULT_TAB: MainTab = 'chat'; + +/** + * Serialize application state to URL search parameters. + * Only includes parameters that differ from defaults to keep URLs clean. + */ +export function serializeRoute(state: AppRouteState): URLSearchParams { + const params = new URLSearchParams(); + + // Session ID - always include if present + if (state.sessionId && state.sessionId.trim().length > 0) { + params.set(ROUTE_PARAMS.SESSION, state.sessionId); + } + + // Settings takes precedence - if open, include settings section + if (state.isSettingsOpen) { + const settingsSection = state.settingsSection === 'sessions' ? 'settings' : state.settingsSection; + params.set(ROUTE_PARAMS.SETTINGS, settingsSection); + // Don't include tab when settings is open (it's a full-screen overlay) + return params; + } + + // Tab - only include if not the default + if (state.tab !== DEFAULT_TAB) { + params.set(ROUTE_PARAMS.TAB, state.tab); + } + + // Diff file - only include when on diff tab + if (state.tab === 'diff' && state.diffFile && state.diffFile.trim().length > 0) { + params.set(ROUTE_PARAMS.FILE, state.diffFile); + } + + return params; +} + +/** + * Convert URLSearchParams to a URL string. + * Returns just the pathname if no params, otherwise pathname + search string. + */ +export function buildURL(params: URLSearchParams, pathname?: string): string { + const path = pathname ?? (typeof window !== 'undefined' ? window.location.pathname : '/'); + const search = params.toString(); + + if (!search) { + return path; + } + + return `${path}?${search}`; +} + +/** + * Check if the current URL matches the given route state. + * Used to avoid unnecessary URL updates. + */ +export function routeMatchesURL(state: AppRouteState): boolean { + if (typeof window === 'undefined') { + return true; + } + + try { + const currentParams = new URLSearchParams(window.location.search); + const newParams = serializeRoute(state); + + // Compare sorted param strings for equality + const currentSorted = [...currentParams.entries()].sort((a, b) => a[0].localeCompare(b[0])); + const newSorted = [...newParams.entries()].sort((a, b) => a[0].localeCompare(b[0])); + + if (currentSorted.length !== newSorted.length) { + return false; + } + + for (let i = 0; i < currentSorted.length; i++) { + if (currentSorted[i][0] !== newSorted[i][0] || currentSorted[i][1] !== newSorted[i][1]) { + return false; + } + } + + return true; + } catch { + return true; + } +} + +/** + * Update the browser URL using pushState or replaceState. + * Does nothing if URL already matches or in VS Code context. + */ +export function updateBrowserURL( + state: AppRouteState, + options: { replace?: boolean; force?: boolean } = {} +): void { + if (typeof window === 'undefined') { + return; + } + + // Skip URL updates in VS Code webview + if (isVSCodeContext()) { + return; + } + + // Skip if URL already matches (unless forced) + if (!options.force && routeMatchesURL(state)) { + return; + } + + try { + const params = serializeRoute(state); + const url = buildURL(params); + + if (options.replace) { + window.history.replaceState({ ...window.history.state, route: state }, '', url); + } else { + window.history.pushState({ route: state }, '', url); + } + } catch { + // Silently fail - URL updates are non-critical + } +} + +/** + * Check if running in VS Code webview context. + */ +function isVSCodeContext(): boolean { + if (typeof window === 'undefined') { + return false; + } + + // Check for VS Code config object + const win = window as { __VSCODE_CONFIG__?: unknown }; + return win.__VSCODE_CONFIG__ !== undefined; +} diff --git a/packages/ui/src/lib/router/types.ts b/packages/ui/src/lib/router/types.ts new file mode 100644 index 00000000..a0f212b7 --- /dev/null +++ b/packages/ui/src/lib/router/types.ts @@ -0,0 +1,54 @@ +import type { SidebarSection } from '@/constants/sidebar'; +import type { MainTab } from '@/stores/useUIStore'; + +/** + * Represents the current route state derived from URL parameters. + * All fields are nullable - null means "not specified in URL" (use app defaults). + */ +export interface RouteState { + /** Session ID to navigate to */ + sessionId: string | null; + /** Main tab to display (chat, git, diff, terminal, files) */ + tab: MainTab | null; + /** Settings section - when non-null, settings dialog should be open */ + settingsSection: SidebarSection | null; + /** File path for diff view */ + diffFile: string | null; +} + +/** + * Context for router operations - determines what capabilities are available. + */ +export interface RouterContext { + /** Whether running in VS Code webview (limited URL capabilities) */ + isVSCode: boolean; + /** Whether URL can be updated (false in VS Code, true elsewhere) */ + canUpdateURL: boolean; +} + +/** + * Valid main tab values for URL routing. + */ +export const VALID_TABS: readonly MainTab[] = ['chat', 'git', 'diff', 'terminal', 'files'] as const; + +/** + * Valid settings section values for URL routing. + */ +export const VALID_SETTINGS_SECTIONS: readonly SidebarSection[] = [ + 'settings', + 'agents', + 'commands', + 'skills', + 'providers', + 'git-identities', +] as const; + +/** + * URL parameter names used for routing. + */ +export const ROUTE_PARAMS = { + SESSION: 'session', + TAB: 'tab', + SETTINGS: 'settings', + FILE: 'file', +} as const;