diff --git a/packages/ui/src/components/comments/useInlineCommentController.ts b/packages/ui/src/components/comments/useInlineCommentController.ts index 7009aef0..b8862039 100644 --- a/packages/ui/src/components/comments/useInlineCommentController.ts +++ b/packages/ui/src/components/comments/useInlineCommentController.ts @@ -12,7 +12,6 @@ import { useI18n } from '@/lib/i18n'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { focusChatInput } from '@/components/chat/composer/editor/dom'; -import { useUIStore } from '@/stores/useUIStore'; type LineRangeBase = { start: number; @@ -164,7 +163,6 @@ export function useInlineCommentController( reset(); if (isNewComment) { - useUIStore.getState().setActiveMainTab('chat'); requestAnimationFrame(focusChatInput); } }, [addDraft, editingDraftId, fileLabel, getCodeForRange, language, reset, selection, source, t, target, toStoreRange, updateDraft]); diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 28e44ce8..1a25e06c 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -45,7 +45,7 @@ const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/Se export const MainLayout: React.FC = () => { const isSidebarOpen = useUIStore((state) => state.isSidebarOpen); - const activeMainTab = useUIStore((state) => state.activeMainTab); + const activeSurface = useUIStore((state) => state.activeSurface); const setIsMobile = useUIStore((state) => state.setIsMobile); const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen); const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen); @@ -83,7 +83,7 @@ export const MainLayout: React.FC = () => { if (sessionSelected || draftOpened) closeSurfacePages(); }); const unsubscribeTab = useUIStore.subscribe((state, prev) => { - if (state.activeMainTab !== prev.activeMainTab) closeSurfacePages(); + if (state.activeSurface !== prev.activeSurface) closeSurfacePages(); }); return () => { unsubscribeSession(); @@ -195,7 +195,7 @@ export const MainLayout: React.FC = () => { }, [isMobile, setMobileSessionPanelOpen]); useEffect(() => { - if (!isMobile || activeMainTab !== 'chat' || mobileLeftDrawerOpen || mobileRightSidebarOpen || isSettingsDialogOpen) { + if (!isMobile || activeSurface !== 'chat' || mobileLeftDrawerOpen || mobileRightSidebarOpen || isSettingsDialogOpen) { return; } @@ -231,7 +231,7 @@ export const MainLayout: React.FC = () => { window.clearTimeout(timeoutId); } }; - }, [activeMainTab, isMobile, isSettingsDialogOpen, mobileLeftDrawerOpen, mobileRightSidebarOpen]); + }, [activeSurface, isMobile, isSettingsDialogOpen, mobileLeftDrawerOpen, mobileRightSidebarOpen]); // Ensure mobile drawers are closed when opening full-screen settings useEffect(() => { @@ -263,10 +263,10 @@ export const MainLayout: React.FC = () => { // Desktop surfaces live in the context panel; the only full-view // overlays left there are the terminal (promoted by project actions) // and the diagram viewer. Mobile keeps the full tab set. - if (!isMobile && activeMainTab !== 'terminal' && activeMainTab !== 'diagram') { + if (!isMobile && activeSurface !== 'terminal' && activeSurface !== 'diagram') { return null; } - switch (activeMainTab) { + switch (activeSurface) { case 'plan': return ; case 'git': @@ -284,9 +284,9 @@ export const MainLayout: React.FC = () => { default: return null; } - }, [activeMainTab, isMobile, mobileRightSidebarOpen]); + }, [activeSurface, isMobile, mobileRightSidebarOpen]); - const isChatActive = activeMainTab === 'chat'; + const isChatActive = activeSurface === 'chat'; return ( diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index c97f608e..e4da0d45 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -147,8 +147,8 @@ export const TerminalView: React.FC = ({ visible }) => { terminalControllerRef.current?.focus(); }, [useTouchTerminalInput]); - const activeMainTab = useUIStore((state) => state.activeMainTab); - const isTerminalActive = activeMainTab === 'terminal'; + const activeSurface = useUIStore((state) => state.activeSurface); + const isTerminalActive = activeSurface === 'terminal'; const isTerminalVisible = visible ?? isTerminalActive; const [hasOpenedTerminalViewport, setHasOpenedTerminalViewport] = React.useState(isTerminalVisible); diff --git a/packages/ui/src/hooks/useRouter.ts b/packages/ui/src/hooks/useRouter.ts index fa794ad5..1e34e765 100644 --- a/packages/ui/src/hooks/useRouter.ts +++ b/packages/ui/src/hooks/useRouter.ts @@ -3,7 +3,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; 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 type { WorkspaceSurface } from '@/stores/useUIStore'; import { resolveSettingsSlug } from '@/lib/settings/metadata'; import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat'; @@ -49,7 +49,7 @@ export function useRouter(): void { // Get store actions (stable references) const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); - const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); + const setActiveSurface = useUIStore((state) => state.setActiveSurface); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); const navigateToDiff = useUIStore((state) => state.navigateToDiff); @@ -75,11 +75,11 @@ export function useRouter(): void { } } - // 2. Handle settings (takes precedence over tabs - it's a full-screen overlay) + // 2. Handle settings first because it is a full-screen overlay. if (route.settingsPath) { setSettingsPage(resolveSettingsSlug(route.settingsPath)); setSettingsDialogOpen(true); - // Don't process tab when settings is open + // Do not process a route view while settings is open. return; } @@ -88,9 +88,9 @@ export function useRouter(): void { setSettingsDialogOpen(false); } - // 3. Apply tab + // 3. Apply the view selected by the legacy URL parameter. if (route.tab) { - setActiveMainTab(route.tab); + setActiveSurface(route.tab); } // 4. Apply diff file (only if going to diff tab) @@ -101,7 +101,7 @@ export function useRouter(): void { isApplyingRouteRef.current = false; } }, - [setCurrentSession, setActiveMainTab, setSettingsDialogOpen, setSettingsPage, navigateToDiff] + [setCurrentSession, setActiveSurface, setSettingsDialogOpen, setSettingsPage, navigateToDiff] ); /** @@ -113,7 +113,7 @@ export function useRouter(): void { return { sessionId: sessionState.currentSessionId, - tab: uiState.activeMainTab, + tab: uiState.activeSurface, isSettingsOpen: uiState.isSettingsDialogOpen, settingsPath: uiState.settingsPage, diffFile: uiState.pendingDiffFile, @@ -162,7 +162,7 @@ export function useRouter(): void { updateBrowserURL({ ...getCurrentAppState(), sessionId: route.sessionId ?? useSessionUIStore.getState().currentSessionId, - tab: route.tab ?? useUIStore.getState().activeMainTab, + tab: route.tab ?? useUIStore.getState().activeSurface, settingsPath: route.settingsPath ?? useUIStore.getState().settingsPage, diffFile: route.diffFile ?? useUIStore.getState().pendingDiffFile, }, { replace: true, force: true }); @@ -195,13 +195,13 @@ export function useRouter(): void { return unsubscribe; }, [isVSCode, isEmbeddedChat, syncURLFromState]); - // Subscribe to UI store changes (tab, settings) + // Subscribe to UI store changes (view, settings) React.useEffect(() => { if (isVSCode || isEmbeddedChat) { return; } - let prevTab: MainTab = useUIStore.getState().activeMainTab; + let prevSurface: WorkspaceSurface = useUIStore.getState().activeSurface; let prevSettingsOpen: boolean = useUIStore.getState().isSettingsDialogOpen; let prevSettingsPath: string = useUIStore.getState().settingsPage; let prevDiffFile: string | null = useUIStore.getState().pendingDiffFile; @@ -212,19 +212,19 @@ export function useRouter(): void { return; } - const tabChanged = state.activeMainTab !== prevTab; + const surfaceChanged = state.activeSurface !== prevSurface; const settingsOpenChanged = state.isSettingsDialogOpen !== prevSettingsOpen; const settingsPathChanged = state.settingsPage !== prevSettingsPath; - const diffFileChanged = state.pendingDiffFile !== prevDiffFile && state.activeMainTab === 'diff'; + const diffFileChanged = state.pendingDiffFile !== prevDiffFile && state.activeSurface === 'diff'; // Update tracking vars - prevTab = state.activeMainTab; + prevSurface = state.activeSurface; prevSettingsOpen = state.isSettingsDialogOpen; prevSettingsPath = state.settingsPage; prevDiffFile = state.pendingDiffFile; // Only sync if something relevant changed - if (tabChanged || settingsOpenChanged || settingsPathChanged || diffFileChanged) { + if (surfaceChanged || settingsOpenChanged || settingsPathChanged || diffFileChanged) { syncURLFromState(); } }); @@ -252,9 +252,9 @@ export function useRouter(): void { if (uiState.isSettingsDialogOpen) { setSettingsDialogOpen(false); } - // Reset to chat tab if not already there - if (uiState.activeMainTab !== 'chat') { - setActiveMainTab('chat'); + // Reset to chat when no route view is specified. + if (uiState.activeSurface !== 'chat') { + setActiveSurface('chat'); } } }; @@ -264,5 +264,5 @@ export function useRouter(): void { return () => { window.removeEventListener('popstate', handlePopState); }; - }, [applyRoute, isVSCode, isEmbeddedChat, setActiveMainTab, setSettingsDialogOpen]); + }, [applyRoute, isVSCode, isEmbeddedChat, setActiveSurface, setSettingsDialogOpen]); } diff --git a/packages/ui/src/lib/router/index.ts b/packages/ui/src/lib/router/index.ts index c6f2f23c..dc68e1f8 100644 --- a/packages/ui/src/lib/router/index.ts +++ b/packages/ui/src/lib/router/index.ts @@ -6,15 +6,15 @@ * * URL Schema: * - `?session=` - Navigate to specific session - * - `?tab=` - Active main tab + * - `?tab=` - Legacy URL name for the active workspace surface * - `?settings=
` - Open settings to specific section * - `?file=` - Diff view with file selected * * Examples: * - `/?session=abc123` - Open session abc123 - * - `/?tab=git` - Open git tab + * - `/?tab=git` - Open the Git surface * - `/?settings=providers` - Open settings to providers section - * - `/?tab=diff&file=src/main.ts` - Open diff view with file + * - `/?tab=diff&file=src/main.ts` - Open the Diff surface with a file */ export type { RouteState } from './types'; diff --git a/packages/ui/src/lib/router/parseRoute.ts b/packages/ui/src/lib/router/parseRoute.ts index 23addc40..458fb4f9 100644 --- a/packages/ui/src/lib/router/parseRoute.ts +++ b/packages/ui/src/lib/router/parseRoute.ts @@ -1,4 +1,4 @@ -import type { MainTab } from '@/stores/useUIStore'; +import type { WorkspaceSurface } from '@/stores/useUIStore'; import { type RouteState, VALID_TABS, @@ -52,13 +52,13 @@ function parseSessionId(params: URLSearchParams): string | null { * Parse main tab from URL parameters. * Returns null if missing or invalid. */ -function parseTab(params: URLSearchParams): MainTab | null { +function parseTab(params: URLSearchParams): WorkspaceSurface | null { const value = params.get(ROUTE_PARAMS.TAB); if (!value) { return null; } - const normalized = value.toLowerCase().trim() as MainTab; + const normalized = value.toLowerCase().trim() as WorkspaceSurface; if (VALID_TABS.includes(normalized)) { return normalized; } diff --git a/packages/ui/src/lib/router/serializeRoute.ts b/packages/ui/src/lib/router/serializeRoute.ts index c4c27c11..6b6e054c 100644 --- a/packages/ui/src/lib/router/serializeRoute.ts +++ b/packages/ui/src/lib/router/serializeRoute.ts @@ -1,4 +1,4 @@ -import type { MainTab } from '@/stores/useUIStore'; +import type { WorkspaceSurface } from '@/stores/useUIStore'; import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat'; import { ROUTE_PARAMS } from './types'; @@ -7,7 +7,7 @@ import { ROUTE_PARAMS } from './types'; */ export interface AppRouteState { sessionId: string | null; - tab: MainTab; + tab: WorkspaceSurface; isSettingsOpen: boolean; settingsPath: string; diffFile: string | null; @@ -16,7 +16,7 @@ export interface AppRouteState { /** * Default tab when none is specified. */ -const DEFAULT_TAB: MainTab = 'chat'; +const DEFAULT_TAB: WorkspaceSurface = 'chat'; /** * Serialize application state to URL search parameters. diff --git a/packages/ui/src/lib/router/types.ts b/packages/ui/src/lib/router/types.ts index 3978b450..9c5801e2 100644 --- a/packages/ui/src/lib/router/types.ts +++ b/packages/ui/src/lib/router/types.ts @@ -1,5 +1,5 @@ import type { SidebarSection } from '@/constants/sidebar'; -import type { MainTab } from '@/stores/useUIStore'; +import type { WorkspaceSurface } from '@/stores/useUIStore'; /** * Represents the current route state derived from URL parameters. @@ -8,8 +8,8 @@ import type { MainTab } from '@/stores/useUIStore'; export interface RouteState { /** Session ID to navigate to */ sessionId: string | null; - /** Main tab to display (chat, git, diff, terminal, files) */ - tab: MainTab | null; + /** View selected through the legacy `tab` URL parameter. */ + tab: WorkspaceSurface | null; /** Settings section - when non-null, settings dialog should be open */ settingsPath: string | null; /** File path for diff view */ @@ -17,9 +17,9 @@ export interface RouteState { } /** - * Valid main tab values for URL routing. + * Valid values for the legacy `tab` URL parameter. */ -export const VALID_TABS: readonly MainTab[] = ['chat', 'git', 'diff', 'terminal', 'files', 'diagram'] as const; +export const VALID_TABS: readonly WorkspaceSurface[] = ['chat', 'git', 'diff', 'terminal', 'files', 'diagram'] as const; /** * Valid settings section values for URL routing. diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 6ef48876..49ed4a87 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -38,7 +38,7 @@ Examples: - `useFeatureFlagsStore.ts` - `useUpdateStore.ts` -These stores coordinate visible app state, navigation, selected tabs, dialogs, and lightweight feature flags. +These stores coordinate visible app state, navigation, selected context-panel tabs, dialogs, and lightweight feature flags. `useUIStore.activeSurface` selects the primary mobile view and the few desktop views that are promoted out of the context panel. It is not a desktop tab selection. Context-panel session chats mount only the active chat iframe. After installing its message listener, the iframe requests its authoritative visibility from the diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 24235b3b..7c772122 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -13,7 +13,13 @@ import { useFilesViewTabsStore } from './useFilesViewTabsStore'; import { isWindowsArm64 } from '@/lib/platform'; import { isVSCodeRuntime } from '@/lib/desktop'; -export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram'; +/** + * The primary view on mobile and the desktop's promoted full-screen view. + * Desktop context-panel content is not represented here. + */ +export type WorkspaceSurface = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram'; +/** @deprecated Use WorkspaceSurface. */ +export type MainTab = WorkspaceSurface; export type PendingDiffScope = 'working' | 'staged' | 'turn'; export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal'; export type MermaidRenderingMode = 'svg' | 'ascii'; @@ -77,7 +83,9 @@ type PendingFileNavigation = { column: number; }; -export type MainTabGuard = (nextTab: MainTab) => boolean; +export type WorkspaceSurfaceGuard = (nextSurface: WorkspaceSurface) => boolean; +/** @deprecated Use WorkspaceSurfaceGuard. */ +export type MainTabGuard = WorkspaceSurfaceGuard; export type EventStreamStatus = | 'idle' | 'connecting' @@ -129,7 +137,7 @@ const CONTEXT_PANEL_MAX_WIDTH = 1400; const CONTEXT_PANEL_MAX_TABS = 12; const CONTEXT_PANEL_MAX_LABEL_LENGTH = 120; const LEFT_SIDEBAR_MIN_WIDTH = 280; -const activeMainTabByRuntime = new Map(); +const activeSurfaceByRuntime = new Map(); /** Separates browser tabs opened in the same millisecond. */ let browserTabSequence = 0; @@ -648,8 +656,12 @@ interface UIStore { workStatusHiddenSections: string[]; isSessionSwitcherOpen: boolean; isSessionDropdownOpen: boolean; - activeMainTab: MainTab; - mainTabGuard: MainTabGuard | null; + activeSurface: WorkspaceSurface; + surfaceGuard: WorkspaceSurfaceGuard | null; + /** @deprecated Use activeSurface. */ + activeMainTab: WorkspaceSurface; + /** @deprecated Use surfaceGuard. */ + mainTabGuard: WorkspaceSurfaceGuard | null; sidebarOpenBeforeFullscreenTab: boolean | null; pendingDiffFile: string | null; pendingDiffStaged: boolean; @@ -834,10 +846,14 @@ interface UIStore { setWorkStatusHiddenSections: (sectionIds: string[]) => void; setSessionSwitcherOpen: (open: boolean) => void; setSessionDropdownOpen: (open: boolean) => void; - setActiveMainTab: (tab: MainTab) => void; + setActiveSurface: (surface: WorkspaceSurface) => void; + /** @deprecated Use setActiveSurface. */ + setActiveMainTab: (surface: WorkspaceSurface) => void; prepareForRuntimeSwitch: (runtimeKey?: string | null) => void; restoreForRuntimeSwitch: (runtimeKey?: string | null) => void; - setMainTabGuard: (guard: MainTabGuard | null) => void; + setSurfaceGuard: (guard: WorkspaceSurfaceGuard | null) => void; + /** @deprecated Use setSurfaceGuard. */ + setMainTabGuard: (guard: WorkspaceSurfaceGuard | null) => void; setPendingDiffFile: (filePath: string | null, staged?: boolean, scope?: PendingDiffScope | null) => void; setPendingDiagramFile: (filePath: string | null) => void; setPendingFileNavigation: (navigation: PendingFileNavigation | null) => void; @@ -1011,6 +1027,8 @@ export const useUIStore = create()( workStatusHiddenSections: [], isSessionSwitcherOpen: false, isSessionDropdownOpen: false, + activeSurface: 'chat', + surfaceGuard: null, activeMainTab: 'chat', mainTabGuard: null, sidebarOpenBeforeFullscreenTab: null, @@ -1625,29 +1643,33 @@ export const useUIStore = create()( set({ isSessionDropdownOpen: open }); }, - setMainTabGuard: (guard) => { - if (get().mainTabGuard === guard) { + setSurfaceGuard: (guard) => { + if (get().surfaceGuard === guard) { return; } - set({ mainTabGuard: guard }); + set({ surfaceGuard: guard, mainTabGuard: guard }); }, - setActiveMainTab: (tab) => { - const guard = get().mainTabGuard; - if (guard && !guard(tab)) { + setMainTabGuard: (guard) => get().setSurfaceGuard(guard), + + setActiveSurface: (surface) => { + const guard = get().surfaceGuard; + if (guard && !guard(surface)) { return; } - activeMainTabByRuntime.set(runtimeMemoryKey(), tab); - set({ activeMainTab: tab }); + activeSurfaceByRuntime.set(runtimeMemoryKey(), surface); + set({ activeSurface: surface, activeMainTab: surface }); }, + setActiveMainTab: (surface) => get().setActiveSurface(surface), + prepareForRuntimeSwitch: (runtimeKey?: string | null) => { - activeMainTabByRuntime.set(runtimeMemoryKey(runtimeKey), get().activeMainTab); + activeSurfaceByRuntime.set(runtimeMemoryKey(runtimeKey), get().activeSurface); }, restoreForRuntimeSwitch: (runtimeKey?: string | null) => { - const restored = activeMainTabByRuntime.get(runtimeMemoryKey(runtimeKey)) ?? 'chat'; - set({ activeMainTab: restored }); + const restored = activeSurfaceByRuntime.get(runtimeMemoryKey(runtimeKey)) ?? 'chat'; + set({ activeSurface: restored, activeMainTab: restored }); }, setPendingDiffFile: (filePath, staged = false, scope = null) => { @@ -1671,11 +1693,11 @@ export const useUIStore = create()( }, navigateToDiff: (filePath, staged = false, scope = null) => { - const guard = get().mainTabGuard; + const guard = get().surfaceGuard; if (guard && !guard('diff')) { return; } - set({ pendingDiffFile: filePath, pendingDiffStaged: staged, pendingDiffScope: scope, activeMainTab: 'diff' }); + set({ pendingDiffFile: filePath, pendingDiffStaged: staged, pendingDiffScope: scope, activeSurface: 'diff', activeMainTab: 'diff' }); }, consumePendingDiffFile: () => { @@ -1687,11 +1709,11 @@ export const useUIStore = create()( }, navigateToDiagram: (filePath) => { - const guard = get().mainTabGuard; + const guard = get().surfaceGuard; if (guard && !guard('diagram')) { return; } - set({ pendingDiagramFile: filePath, activeMainTab: 'diagram' }); + set({ pendingDiagramFile: filePath, activeSurface: 'diagram', activeMainTab: 'diagram' }); }, consumePendingDiagramFile: () => { @@ -2462,13 +2484,20 @@ export const useUIStore = create()( { name: 'ui-store', storage: createDeferredSafeJSONStorage(), - version: 14, + version: 15, migrate: (persistedState, version) => { if (!persistedState || typeof persistedState !== 'object') { return persistedState; } const state = persistedState as Record; + // v14 -> v15: rename the historic main-tab field. The selected + // mobile or promoted desktop view remains unchanged. + if (version < 15) { + state.activeSurface = state.activeMainTab; + state.activeMainTab = state.activeSurface; + } + // v13 -> v14: the separate 'preview' surface merged into 'browser'. // Stored preview tabs keep their URL and become browser tabs; their // id encodes the mode, so it is rebuilt rather than left dangling. @@ -2674,7 +2703,9 @@ export const useUIStore = create()( workStatusPanelEnabled: state.workStatusPanelEnabled, workStatusHiddenSections: state.workStatusHiddenSections, isSessionSwitcherOpen: state.isSessionSwitcherOpen, - activeMainTab: state.activeMainTab, + activeSurface: state.activeSurface, + // Keep the deprecated mirror synchronized while consumers migrate. + activeMainTab: state.activeSurface, sidebarSection: state.sidebarSection, settingsPage: state.settingsPage, settingsHasOpenedOnce: state.settingsHasOpenedOnce,