feat: Add Routes (#197)

* Add routes

* fix: normalize router URL sync

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Taylor Beeston
2026-01-22 21:52:22 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 2618efe8e5
commit 031842f5b5
8 changed files with 738 additions and 46 deletions
+31
View File
@@ -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=<id>` - Navigate to specific session
* - `?tab=<chat|git|diff|terminal|files>` - Active main tab
* - `?settings=<section>` - Open settings to specific section
* - `?file=<path>` - 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';
+133
View File
@@ -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;
}
}
@@ -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;
}
+54
View File
@@ -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;