Files
openchamber/packages/ui/src/hooks/useRouter.ts
T

249 lines
7.6 KiB
TypeScript
Raw Normal View History

2026-01-22 11:52:22 -08:00
import React from 'react';
import { useSessionUIStore } from '@/sync/session-ui-store';
2026-01-22 11:52:22 -08:00
import { useUIStore } from '@/stores/useUIStore';
import { parseRoute, updateBrowserURL, hasRouteParams } from '@/lib/router';
import type { RouteState, AppRouteState } from '@/lib/router';
import type { MainTab } from '@/stores/useUIStore';
import { resolveSettingsSlug } from '@/lib/settings/metadata';
2026-01-22 11:52:22 -08:00
/**
* 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: Full bidirectional sync
2026-01-22 11:52:22 -08:00
* - 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 = useSessionUIStore((state) => state.setCurrentSession);
2026-01-22 11:52:22 -08:00
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
2026-01-22 11:52:22 -08:00
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 = useSessionUIStore.getState().currentSessionId;
2026-01-22 11:52:22 -08:00
if (route.sessionId !== currentSessionId) {
await setCurrentSession(route.sessionId);
}
}
// 2. Handle settings (takes precedence over tabs - it's a full-screen overlay)
if (route.settingsPath) {
setSettingsPage(resolveSettingsSlug(route.settingsPath));
2026-01-22 11:52:22 -08:00
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, setSettingsPage, navigateToDiff]
2026-01-22 11:52:22 -08:00
);
/**
* Get current app state for URL serialization.
*/
const getCurrentAppState = React.useCallback((): AppRouteState => {
const sessionState = useSessionUIStore.getState();
2026-01-22 11:52:22 -08:00
const uiState = useUIStore.getState();
return {
sessionId: sessionState.currentSessionId,
tab: uiState.activeMainTab,
isSettingsOpen: uiState.isSettingsDialogOpen,
settingsPath: uiState.settingsPage,
2026-01-22 11:52:22 -08:00
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 = useSessionUIStore.getState().currentSessionId;
2026-01-22 11:52:22 -08:00
const unsubscribe = useSessionUIStore.subscribe((state) => {
2026-01-22 11:52:22 -08:00
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 prevSettingsPath: string = useUIStore.getState().settingsPage;
2026-01-22 11:52:22 -08:00
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 settingsPathChanged = state.settingsPage !== prevSettingsPath;
2026-01-22 11:52:22 -08:00
const diffFileChanged = state.pendingDiffFile !== prevDiffFile && state.activeMainTab === 'diff';
// Update tracking vars
prevTab = state.activeMainTab;
prevSettingsOpen = state.isSettingsDialogOpen;
prevSettingsPath = state.settingsPage;
2026-01-22 11:52:22 -08:00
prevDiffFile = state.pendingDiffFile;
// Only sync if something relevant changed
if (tabChanged || settingsOpenChanged || settingsPathChanged || diffFileChanged) {
2026-01-22 11:52:22 -08:00
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]);
}