diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index c1421ba2499..0406a361f96 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -178,7 +178,6 @@ inline src/relay/pty-handler.test.ts inline src/relay/pty-handler.ts inline src/relay/relay.ts inline src/relay/workspace-space-scan.ts -inline src/renderer/src/App.tsx inline src/renderer/src/components/GitHubItemDialog.tsx inline src/renderer/src/components/GitLabItemDialog.tsx inline src/renderer/src/components/JiraIssueWorkspace.tsx diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f85eb212265..b586b424b35 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,2256 +1,64 @@ -/* eslint-disable max-lines */ -import { - Suspense, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, - type SetStateAction -} from 'react' -import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' - -import { - ArrowLeft, - ArrowRight, - Minimize2, - MoreHorizontal, - PanelLeft, - PanelRight -} from 'lucide-react' -import logo from '../../../resources/logo.svg' -import { SYNC_FIT_PANES_EVENT, TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal' -import { syncZoomCSSVar } from '@/lib/ui-zoom' -import { resolveLeftSidebarStyleVariables } from '@/lib/left-sidebar-appearance' -import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility' -import { - isPairedWebClientWindow, - shouldRenderDesktopWindowChrome -} from '@/lib/desktop-window-chrome' -import { resolveLeftTitlebarChromeLayout } from '@/lib/titlebar-left-chrome' -import { shouldShowWorktreeCreationSurface } from '@/lib/worktree-creation-surface' -import { buildAppFontFamily } from '@/lib/app-font-family' -import { toast } from 'sonner' +import { useCallback } from 'react' import { Toaster } from '@/components/ui/sonner' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger -} from '@/components/ui/context-menu' -import { useAppStore } from './store' -import { WORKTREE_REFRESH_CONCURRENCY } from './store/slices/worktrees' -import { useShallow } from 'zustand/react/shallow' -import { isRemoteWorkspaceSnapshotApplyInProgress, useIpcEvents } from './hooks/useIpcEvents' -import { useAutomationDispatchEvents } from './hooks/useAutomationDispatchEvents' -import RetainedAgentsSyncGate from './components/dashboard/RetainedAgentsSyncGate' -import { AgentHibernationGate } from './components/AgentHibernationGate' -import { AiVaultTabTitleSyncGate } from './components/AiVaultTabTitleSyncGate' -import { ActivityTitlebarControls } from './components/activity/ActivityTitlebarControls' -import Sidebar from './components/Sidebar' -import { shutdownBufferCaptures } from './components/terminal-pane/shutdown-buffer-captures' -import { dispatchWindowCloseRequest } from './components/window-close-request-coordinator' -import { - getSystemPrefersDarkSnapshot, - useSystemPrefersDark -} from './components/terminal-pane/use-system-prefers-dark' -import RightSidebar from './components/right-sidebar' -import { StarNagCard } from './components/StarNagCard' -import { StarNagAgentValueMomentObserver } from './components/star-nag/StarNagAgentValueMomentObserver' -import { StarNagToastHost } from './components/star-nag/StarNagToastHost' -import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge' -import { SkillFreshnessUpdateDialog } from './components/skills/SkillFreshnessUpdateDialog' -import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface' -import { ZoomOverlay } from './components/ZoomOverlay' -import { onOnboardingReopened } from './components/onboarding/show-onboarding-event' -import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding' -import { MarkdownTemplatePicker } from './components/editor/MarkdownTemplatePicker' -import { FloatingTerminalToggleButton } from './components/floating-terminal/FloatingTerminalToggleButton' -import { - persistFloatingTerminalPanelOpen, - readPersistedFloatingTerminalPanelViewState -} from './components/floating-terminal/floating-terminal-panel-view-state' -import { - TOGGLE_FLOATING_TERMINAL_EVENT, - requestFloatingTerminalOpenMaximized -} from '@/lib/floating-terminal' -import { - isFloatingWorkspacePanelFocused, - isFloatingWorkspaceTerminalInputTarget, - matchFloatingWorkspacePanelChord, - shouldMinimizeFloatingWorkspacePanelOnCloseShortcut -} from '@/lib/floating-workspace-terminal-actions' -import { createFloatingWorkspaceTourInteractionSnapshot } from '@/lib/floating-workspace-tour-interaction-snapshot' -import { requestScrollToCurrentWorkspaceRevealAndRename } from '@/lib/scroll-to-current-workspace-status' -import { TOGGLE_WORKSPACE_BOARD_EVENT } from './components/sidebar/useWorkspaceBoardPanel' -import { WorkspacePortScanner } from './components/ports/WorkspacePortScanner' -import { CrashReportDialog } from './components/crash-report/CrashReportDialog' -import NewWorkspaceComposerModal from './components/NewWorkspaceComposerModal' -import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary' +import { TooltipProvider } from '@/components/ui/tooltip' import { ConfirmationDialogProvider } from './components/confirmation-dialog' import { LinkRoutingPreferenceDialogProvider } from './components/link-routing-preference-dialog' -import RecentTabSwitcher from './components/tab-bar/RecentTabSwitcher' -import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling' -import { useEditorExternalWatch } from './hooks/useEditorExternalWatch' -import { useAutoAckViewedAgent } from './hooks/useAutoAckViewedAgent' -import { useUnreadDockBadge } from './hooks/useUnreadDockBadge' -import { - resolvePrimarySelectionMiddleClickPaste, - usePrimarySelectionPaste -} from './hooks/usePrimarySelectionPaste' -import { useAppMenuPaste } from './hooks/useAppMenuPaste' -import { useAppMenuSelectionActions } from './hooks/useAppMenuSelectionActions' -import { useLargeTextControlPaste } from './hooks/useLargeTextControlPaste' -import { - canSkipRuntimeMobileSessionSyncKeyBuild, - getRuntimeMobileSessionSyncKey, - runtimeMobileSessionSyncKeysEqual, - scheduleRuntimeGraphSync, - setRuntimeGraphStoreStateGetter, - setRuntimeGraphSyncEnabled -} from './runtime/sync-runtime-graph' -import { useWebSessionTabsSync } from './runtime/web-session-tabs-sync' -import { useGlobalFileDrop } from './hooks/useGlobalFileDrop' -import { MacosTccPromptNoticeHost } from './hooks/MacosTccPromptNoticeHost' -import { useRadixBodyPointerEventsRecovery } from './hooks/useRadixBodyPointerEventsRecovery' -import { - isIntentionalAppRestartInProgress, - registerUpdaterBeforeUnloadBypass -} from './lib/updater-beforeunload' -import { - ORCA_APP_RESTART_ABORTED_EVENT, - ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT -} from '../../shared/updater-renderer-events' -import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../shared/renderer-shutdown-events' -import { - buildWorkspaceSessionPayload, - shouldPersistWorkspaceSession -} from './lib/workspace-session' -import { createSessionWriteSubscriber } from './lib/session-write-subscriber' -import { sweepRestoredCodexPanesForStaleAccounts } from './lib/codex-stale-pane-sweep' -import { installCodexDetachedPaneRestartExecutor } from '@/components/terminal-pane/codex-detached-pane-restart-scheduler' -import { buildActiveViewUnloadPatch } from './lib/active-view-persist' -import { - buildWorkspaceSessionHostSnapshots, - fetchWorkspaceSessionWithRuntimeHostOwners, - patchWorkspaceSessionByHost -} from './lib/workspace-session-host-persistence' -import { - createShutdownCheckpointBeforeUnloadHandler, - createShutdownCheckpointGuard -} from './lib/shutdown-checkpoint-guard' -import { - collectFolderWorkspaceKeysFromSession, - collectWorktreeHydrationRepoIdsFromSession -} from './lib/workspace-session-hydration-keys' -import { - getStartupErrorFallbackUI, - hydratePersistedUIAfterStartupRead -} from './lib/startup-ui-hydration' -import { - logRendererStartupDiagnostic, - timeRendererStartupStep, - timeRendererStartupSyncStep -} from './startup/startup-diagnostics' -import { reconnectSshTargetForRendererStartup } from './startup/ssh-startup-reconnect' -import { shouldRenderPetOverlay } from './components/pet/pet-overlay-visibility' -import { applyDocumentTheme } from './lib/document-theme' -import { getSystemPrefersDark } from './lib/terminal-theme' -import { publishTerminalViewAttributesAtAppStart } from './components/terminal-pane/terminal-appearance' -import { isEditableTarget } from './lib/editable-target' -import { getSelectedTextForFileSearch } from './lib/file-search-selection' -import { useShortcutLabel } from './hooks/useShortcutLabel' -import { - folderRelativePathToIncludeGlob, - selectedExplorerFolderRelativePath -} from './components/right-sidebar/file-search-include-pattern' -import { shouldShowWorktreeHistoryControls } from './lib/titlebar-worktree-history-controls' -import { - canGoBackWorktreeHistory, - canGoForwardWorktreeHistory -} from '@/store/slices/worktree-nav-history' -import { selectFloatingVisibleTabCount } from './store/selectors' -import { selectActiveTerminalChromeState } from './store/active-terminal-chrome-selector' -import type { VirtualizedScrollAnchor } from './hooks/useVirtualizedScrollAnchor' -import type { RemoteWorkspacePatchResult } from '../../shared/remote-workspace-types' -import type { OnboardingState } from '../../shared/onboarding-state-types' -import type { UpdateStatus } from '../../shared/update-status-types' -import { - getFeatureTipsAppOpenDecision, - isCliFeatureTipCompleted -} from './components/feature-tips/feature-tip-startup-gate' -import { - trackCmdJPaletteFeatureTipShown, - trackOrcaCliFeatureTipShown -} from './components/feature-tips/feature-tip-telemetry' -import { - keybindingMatchesAction, - type KeybindingActionId, - type KeybindingContext, - type KeybindingMatchOptions, - type PhysicalModifierToken -} from '../../shared/keybindings' -import { PLUGIN_COMMAND_ALIAS_ACTION_IDS } from '../../shared/plugins/plugin-command-actions' -import { registerAppCommandDispatcher } from '@/lib/app-command-dispatch' -import { executePluginCommand } from '@/lib/plugin-command-execution' -import { findPluginCommandForKeybinding } from '@/lib/plugin-command-keybindings' -import { usePluginCommands } from '@/store/plugin-panels' -import { - getRepoExecutionHostId, - isRuntimeOwnedSshTargetId, - parseExecutionHostId, - toRuntimeExecutionHostId, - type ExecutionHostId -} from '../../shared/execution-host' -import { mapWithConcurrency } from '../../shared/map-with-concurrency' -import { - ModifierDoubleTapDetector, - toModifierDoubleTapEvent -} from '../../shared/modifier-double-tap-detector' -import { isGitRepoKind } from '../../shared/repo-kind' -import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' -import { resolveMountedLazyModalIds, type LazyModalId } from './lazy-modal-mount-state' -import { translate } from '@/i18n/i18n' +import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge' import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog' import RunningTerminalCloseDialog from './components/terminal-pane/RunningTerminalCloseDialog' import WorktreeBaseFallbackDialog from './components/WorktreeBaseFallbackDialog' -import { useOsc52ClipboardDefaultOnNotice } from './components/terminal-pane/osc52-clipboard-default-on-notice' -import { - hasRequestedBackgroundTerminalWorktreeMount, - subscribeBackgroundTerminalWorktreeMountRequests -} from './components/terminal/background-terminal-worktree-mount' -import { - collectTerminalProviderSnapshotPtyIds, - refreshTerminalProviderSnapshotCapabilities -} from './components/terminal/terminal-provider-snapshot-capability' -import { useRemoteRuntimeRecoveryTriggers } from './runtime/use-remote-runtime-recovery-triggers' - -// Why: bound the resume-record loss window on a hard kill to ~1 min; capture skips unchanged records so per-tick cost is negligible. -const SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS = 60_000 - -const isMac = navigator.userAgent.includes('Mac') -const isWindows = !isMac && navigator.userAgent.includes('Windows') -const shortcutPlatform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' -// Why: Windows and Linux remove the native title bar so the renderer draws its own chrome; paired web clients run in a browser tab and must not. -const hasCustomTitleBar = shouldRenderDesktopWindowChrome({ - platform: shortcutPlatform, - isWebClient: isPairedWebClientWindow() -}) - -async function listRuntimeSessionHostIdsForStartup(): Promise { - try { - return (await window.api.runtimeEnvironments.list()).map((environment) => - toRuntimeExecutionHostId(environment.id) - ) - } catch (err) { - console.warn('Failed to list runtime session hosts for startup:', err) - return [] - } -} - -function getKeybindingContext(target: EventTarget | null): KeybindingContext { - return target instanceof HTMLElement && target.classList.contains('xterm-helper-textarea') - ? 'terminal' - : 'app' -} - -// Abstraction over a real KeyboardEvent and a synthetic double-tap gesture so one dispatch path serves both; KeybindingInput-compatible. -type ShortcutDispatchInput = { - key?: string - code?: string - altKey?: boolean - metaKey?: boolean - ctrlKey?: boolean - shiftKey?: boolean - doubleTapModifier?: PhysicalModifierToken - target: EventTarget | null - defaultPrevented: boolean - preventDefault: () => void -} - -// Why: Windows and Linux both remove the native title bar, so we render our own min/max/close buttons (Fluent/Win11-style SVGs). -function WindowControls(): React.JSX.Element { - const [maximized, setMaximized] = useState(false) - useEffect(() => { - // Why: maximize-changed only fires on transitions; seed from main on mount so a startup-maximized window shows the right icon. - let cancelled = false - void window.api.ui.isMaximized().then((value) => { - if (!cancelled) { - setMaximized(value) - } - }) - const unsubscribe = window.api.ui.onMaximizeChanged(setMaximized) - return () => { - cancelled = true - unsubscribe() - } - }, []) - return ( -
- - - -
- ) -} - -const Landing = lazy(() => import('./components/Landing')) -const WorktreeCreationPanel = lazy( - () => import('./components/worktree-creation/WorktreeCreationPanel') -) -const TaskPage = lazy(() => import('./components/TaskPage')) -const AutomationsPage = lazy(() => import('./components/automations/AutomationsPage')) -const ActivityPrototypePage = lazy(() => import('./components/activity/ActivityPrototypePage')) -const Settings = lazy(() => import('./components/settings/Settings')) -const ArtifactsPage = lazy(() => import('./components/artifacts/ArtifactsPage')) -const WorkspaceSpacePage = lazy(() => import('./components/workspace-space/WorkspaceSpacePage')) -const MobilePage = lazy(() => import('./components/mobile/MobilePage')) -const QuickOpen = lazy(() => import('./components/QuickOpen')) -const WorktreeJumpPalette = lazy(() => import('./components/WorktreeJumpPalette')) -const WorkspaceCleanupDialog = lazy( - () => import('./components/workspace-cleanup/WorkspaceCleanupDialog') -) -const Terminal = lazy(() => import('./components/Terminal')) -const StatusBar = lazy(() => - import('./components/status-bar/StatusBar').then((module) => ({ default: module.StatusBar })) -) -const SetupGuideModal = lazy(() => import('./components/setup-guide/SetupGuideModal')) -const FeatureWallModal = lazy(() => import('./components/feature-wall/FeatureWallModal')) -const FeatureTipsModal = lazy(() => import('./components/feature-tips/FeatureTipsModal')) -const AddRepoDialog = lazy(() => import('./components/sidebar/AddRepoDialog')) -const NonGitFolderDialog = lazy(() => import('./components/sidebar/NonGitFolderDialog')) -const AddProjectFromFolderDialog = lazy( - () => import('./components/sidebar/AddProjectFromFolderDialog') -) -const ProjectAddedDialog = lazy(() => import('./components/sidebar/ProjectAddedDialog')) -const DeleteWorktreeDialog = lazy(() => import('./components/sidebar/DeleteWorktreeDialog')) -const PreservedBranchBatchReviewModal = lazy( - () => import('./components/sidebar/PreservedBranchBatchReviewModal') -) -const DictationController = lazy(() => - import('./components/dictation/DictationController').then((module) => ({ - default: module.DictationController - })) -) -const SshPassphraseDialog = lazy(() => - import('./components/settings/SshPassphraseDialog').then((module) => ({ - default: module.SshPassphraseDialog - })) -) -const UpdateCard = lazy(() => - import('./components/UpdateCard').then((module) => ({ default: module.UpdateCard })) -) -const RemoteServerUpdateDialog = lazy( - () => import('./components/settings/RemoteServerUpdateDialog') -) -const ContextualTourOverlay = lazy(() => - import('./components/contextual-tours/ContextualTourOverlay').then((module) => ({ - default: module.ContextualTourOverlay - })) -) -const SetupGuideTelemetryObserver = lazy(() => - import('./components/setup-guide/SetupGuideTelemetryObserver').then((module) => ({ - default: module.SetupGuideTelemetryObserver - })) -) -const FloatingTerminalPanel = lazy(() => - import('./components/floating-terminal/FloatingTerminalPanel').then((module) => ({ - default: module.FloatingTerminalPanel - })) -) -// Why: lazy so the WebP asset + overlay module aren't fetched unless the experimental flag is on. -const PetOverlay = lazy(() => import('./components/pet/PetOverlay')) -const DashboardPopoutBridge = lazy(() => import('./components/dashboard/DashboardPopoutBridge')) -// Why: lazy so onboarding's step modules + assets aren't fetched for users past first-launch. -const OnboardingFlow = lazy(() => import('./components/onboarding/OnboardingFlow')) - -function applyRemoteWorkspacePatchStatus( - targetId: string, - result: RemoteWorkspacePatchResult -): void { - const store = useAppStore.getState() - if (result.ok) { - store.setRemoteWorkspaceSyncStatus(targetId, { - phase: 'synced', - direction: 'push', - revision: result.snapshot.revision, - updatedAt: result.snapshot.updatedAt, - lastSyncedAt: Date.now(), - message: translate('auto.App.332dbfa497', 'Workspace uploaded') - }) - return - } - store.setRemoteWorkspaceSyncStatus(targetId, { - phase: result.reason === 'stale-revision' ? 'conflict' : 'offline', - direction: 'push', - revision: result.snapshot?.revision, - updatedAt: result.snapshot?.updatedAt, - lastSyncedAt: Date.now(), - message: - result.message ?? - (result.reason === 'stale-revision' - ? translate( - 'auto.hooks.useIpcEvents.workspaceChangedOnAnotherDevice', - 'Workspace changed on another device' - ) - : translate('auto.hooks.useIpcEvents.2fe88c2e06', 'Remote workspace sync unavailable')) - }) -} - -function shouldMountUpdateCardForStatus(status: UpdateStatus): boolean { - if (status.state === 'idle') { - return false - } - if (status.state === 'checking' || status.state === 'not-available') { - return status.userInitiated === true - } - return true -} +import { useUnreadDockBadge } from './hooks/useUnreadDockBadge' +import { AppBackgroundServices } from './app-shell/AppBackgroundServices' +import { AppRootSurfaces } from './app-shell/AppRootSurfaces' +import { AppWorkspaceShell } from './app-shell/AppWorkspaceShell' +import { WindowControls } from './app-shell/WindowControls' +import { hasCustomTitleBar } from './app-shell/app-window-chrome' +import { useAppChromeLayout } from './app-shell/use-app-chrome-layout' +import { useAppSessionPersistence } from './app-shell/use-app-session-persistence' +import { useAppShellServices } from './app-shell/use-app-shell-services' +import { useAppStartupHydration } from './app-shell/use-app-startup-hydration' +import { useDocumentAppearance } from './app-shell/use-document-appearance' +import { useFloatingWorkspacePanel } from './app-shell/use-floating-workspace-panel' +import { useGlobalKeybindings } from './app-shell/use-global-keybindings' +import { useOnboardingAndFeatureTips } from './app-shell/use-onboarding-and-feature-tips' +import { usePersistedUIWriter } from './app-shell/use-persisted-ui-writer' +import { useRuntimeGraphSync } from './app-shell/use-runtime-graph-sync' +import { useWindowVisibilityEffects } from './app-shell/use-window-visibility-effects' function App(): React.JSX.Element { + const layout = useAppChromeLayout() + const floatingWorkspace = useFloatingWorkspacePanel() + const onboardingGate = useOnboardingAndFeatureTips() const clearUnreadDockBadge = useUnreadDockBadge() - useRadixBodyPointerEventsRecovery() - useWebSessionTabsSync() - // Why restored: leaving the panel closed forces the user to reopen and re-maximize it, - // and that size jump reflows a live TUI's buffer (see floating-terminal-panel-view-state). - const [floatingTerminalOpen, setFloatingTerminalOpen] = useState( - () => readPersistedFloatingTerminalPanelViewState()?.open === true - ) - const floatingWorkspaceTourInteractionSnapshotRef = useRef<{ - wasPreviouslyInteracted?: boolean - persisted?: Promise - recordFeatureInteractionForTour: boolean - } | null>(null) - // Why: consolidate action refs into one useShallow subscription so React runs one equality check per store mutation instead of one per action. - const actions = useAppStore( - useShallow((s) => ({ - toggleSidebar: s.toggleSidebar, - fetchRepos: s.fetchRepos, - fetchReposForAllHosts: s.fetchReposForAllHosts, - awaitLocalRepoCatalogSettlement: s.awaitLocalRepoCatalogSettlement, - fetchProjectGroups: s.fetchProjectGroups, - fetchProjectGroupsForAllHosts: s.fetchProjectGroupsForAllHosts, - fetchFolderWorkspaces: s.fetchFolderWorkspaces, - fetchFolderWorkspacesForAllHosts: s.fetchFolderWorkspacesForAllHosts, - fetchAllWorktrees: s.fetchAllWorktrees, - fetchWorktrees: s.fetchWorktrees, - fetchWorktreeLineage: s.fetchWorktreeLineage, - fetchOrcaProfiles: s.fetchOrcaProfiles, - fetchSettings: s.fetchSettings, - fetchKeybindings: s.fetchKeybindings, - initGitHubCache: s.initGitHubCache, - refreshAllGitHub: s.refreshAllGitHub, - reportVisibleGitHubPRRefreshCandidates: s.reportVisibleGitHubPRRefreshCandidates, - bumpGitHubPRVisibleRefreshGeneration: s.bumpGitHubPRVisibleRefreshGeneration, - hydrateWorkspaceSession: s.hydrateWorkspaceSession, - hydrateTabsSession: s.hydrateTabsSession, - hydrateEditorSession: s.hydrateEditorSession, - hydrateBrowserSession: s.hydrateBrowserSession, - fetchBrowserSessionProfiles: s.fetchBrowserSessionProfiles, - reconnectPersistedTerminals: s.reconnectPersistedTerminals, - setDeferredSshReconnectTargets: s.setDeferredSshReconnectTargets, - setSshConnectionState: s.setSshConnectionState, - hydratePersistedUI: s.hydratePersistedUI, - setHydrationSucceeded: s.setHydrationSucceeded, - openModal: s.openModal, - closeModal: s.closeModal, - markFeatureTipsSeen: s.markFeatureTipsSeen, - setContextualToursAutoEligible: s.setContextualToursAutoEligible, - setContextualToursOnboardingVisible: s.setContextualToursOnboardingVisible, - cancelContextualTour: s.cancelContextualTour, - toggleRightSidebar: s.toggleRightSidebar, - setRightSidebarOpen: s.setRightSidebarOpen, - setRightSidebarTab: s.setRightSidebarTab, - showRightSidebarFiles: s.showRightSidebarFiles, - showRightSidebarSearch: s.showRightSidebarSearch, - openDiffNotesSendMenuForActiveWorktree: s.openDiffNotesSendMenuForActiveWorktree, - setActiveView: s.setActiveView, - updateSettings: s.updateSettings, - pruneLastVisitedTimestamps: s.pruneLastVisitedTimestamps, - seedActiveWorktreeLastVisitedIfMissing: s.seedActiveWorktreeLastVisitedIfMissing - })) - ) - - const activeView = useAppStore((s) => s.activeView) - const activeModal = useAppStore((s) => s.activeModal) - const featureTipsSeenIds = useAppStore((s) => s.featureTipsSeenIds) - const featureInteractions = useAppStore((s) => s.featureInteractions) - const contextualToursAutoEligible = useAppStore((s) => s.contextualToursAutoEligible) - const { - activeWorktreeId, - tabCount, - effectiveActiveTabId, - activeTabCanExpand, - effectiveActiveTabExpanded - } = useAppStore(useShallow(selectActiveTerminalChromeState)) - const activePendingCreationId = useAppStore((s) => s.activePendingCreationId) - // Why: the creation surface owns the tab strip from the first pending frame; gating on the delayed loader flag swapped the tab bar mid-create. - const activePendingCreationExists = useAppStore( - (s) => - s.activePendingCreationId !== null && - s.pendingWorktreeCreations[s.activePendingCreationId] !== undefined - ) - // Why: keep virtualized scroll memory above the sidebar's workspace/landing remount so the left list doesn't restart at scrollTop 0. - const worktreeSidebarScrollOffsetRef = useRef(0) - const worktreeSidebarScrollAnchorRef = useRef(null) - const floatingVisibleTabCount = useAppStore(selectFloatingVisibleTabCount) - const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) - const backgroundTerminalMountRequested = useSyncExternalStore( - subscribeBackgroundTerminalWorktreeMountRequests, - hasRequestedBackgroundTerminalWorktreeMount, - hasRequestedBackgroundTerminalWorktreeMount - ) - const keybindings = useAppStore((s) => s.keybindings) - const pluginCommands = usePluginCommands() - const updateStatus = useAppStore((s) => s.updateStatus) - const activeContextualTourId = useAppStore((s) => s.activeContextualTourId) - const leftSidebarShortcutLabel = useShortcutLabel('sidebar.left.toggle') - const rightSidebarShortcutLabel = useShortcutLabel('sidebar.right.toggle') - const historyBackShortcutLabel = useShortcutLabel('worktree.history.back') - const historyForwardShortcutLabel = useShortcutLabel('worktree.history.forward') - const floatingTerminalEnabled = useAppStore((s) => s.settings?.floatingTerminalEnabled === true) - // Why tracked separately: the flag reads false while settings are still loading, and a - // false read at boot must not be treated as the user disabling the feature. The store - // initializes `settings` to null (not undefined) - fetchSettings replaces it atomically. - const floatingTerminalSettingsHydrated = useAppStore((s) => s.settings != null) - const floatingTerminalTriggerLocation = useAppStore( - (s) => s.settings?.floatingTerminalTriggerLocation ?? 'floating-button' - ) - const statusBarVisible = useAppStore((s) => s.statusBarVisible) - const showFloatingTerminalButton = - floatingTerminalEnabled && - (floatingTerminalTriggerLocation === 'floating-button' || !statusBarVisible) - const hasMountedTerminalWorkbenchRef = useRef(false) - if (activeWorktreeId !== null || backgroundTerminalMountRequested) { - hasMountedTerminalWorkbenchRef.current = true - } - // Why: skip the terminal bundle on the landing path, but once mounted keep hidden panes alive through sleep/shutdown when activeWorktreeId briefly goes null. - const shouldMountTerminalWorkbench = - activeWorktreeId !== null || - backgroundTerminalMountRequested || - hasMountedTerminalWorkbenchRef.current - // Why: visible worktree creation owns its faux tab strip start to finish; keep the previous workspace mounted for retention without real chrome. - const creationLayoutActive = shouldShowWorktreeCreationSurface({ - activeView, - activePendingCreationId, - hasActivePendingCreation: activePendingCreationExists - }) - const workspaceChromeActive = - activeView === 'terminal' && activeWorktreeId !== null && !creationLayoutActive - const terminalWorkbenchVisible = - activeView === 'terminal' && activeWorktreeId !== null && !creationLayoutActive - // Why: once the floating workspace owns tabs, keep it mounted while closed so hidden terminal/browser/editor panes retain local state. - const shouldMountFloatingTerminalPanel = - floatingTerminalEnabled && (floatingTerminalOpen || floatingVisibleTabCount > 0) - // Why: floating workspace is a transient overlay; hotkey minimize returns focus to the surface the user came from. - const floatingTerminalReturnFocusRef = useRef(null) - const floatingTerminalReturnFocusFrameRef = useRef(null) - - const cancelFloatingTerminalReturnFocusFrame = useCallback((): void => { - if (floatingTerminalReturnFocusFrameRef.current === null) { - return - } - cancelAnimationFrame(floatingTerminalReturnFocusFrameRef.current) - floatingTerminalReturnFocusFrameRef.current = null - }, []) + useAppShellServices() + useAppStartupHydration(onboardingGate.applyStartupOnboardingState) + useAppSessionPersistence() + useRuntimeGraphSync() + usePersistedUIWriter() + useDocumentAppearance() + useWindowVisibilityEffects() + useGlobalKeybindings({ layout, floatingWorkspace }) + const { cancelReturnFocusFrame } = floatingWorkspace const setAppRootNode = useCallback( (node: HTMLDivElement | null): void => { // Why: these best-effort App chrome cleanups share the App root lifetime. if (!node) { - cancelFloatingTerminalReturnFocusFrame() + cancelReturnFocusFrame() clearUnreadDockBadge() } }, - [cancelFloatingTerminalReturnFocusFrame, clearUnreadDockBadge] + [cancelReturnFocusFrame, clearUnreadDockBadge] ) - const rememberFloatingTerminalReturnFocus = useCallback((): void => { - const active = document.activeElement - if (!(active instanceof HTMLElement)) { - floatingTerminalReturnFocusRef.current = null - return - } - if ( - active.closest('[data-floating-terminal-panel]') || - active.closest('[data-floating-terminal-toggle]') - ) { - return - } - floatingTerminalReturnFocusRef.current = active - }, []) - - const restoreFloatingTerminalReturnFocus = useCallback((): void => { - const target = floatingTerminalReturnFocusRef.current - floatingTerminalReturnFocusRef.current = null - if (!target || !document.contains(target)) { - return - } - cancelFloatingTerminalReturnFocusFrame() - floatingTerminalReturnFocusFrameRef.current = requestAnimationFrame(() => { - floatingTerminalReturnFocusFrameRef.current = null - if (!document.contains(target)) { - return - } - target.focus({ preventScroll: true }) - }) - }, [cancelFloatingTerminalReturnFocusFrame]) - - const setFloatingTerminalOpenWithFocus = useCallback( - (nextOpen: SetStateAction): void => { - const resolvedOpen = - typeof nextOpen === 'function' ? nextOpen(floatingTerminalOpen) : nextOpen - // Why: recordFeatureInteraction updates Zustand subscribers; running it inside the state updater logs a render-phase update warning. - if (resolvedOpen && !floatingTerminalOpen) { - const state = useAppStore.getState() - floatingWorkspaceTourInteractionSnapshotRef.current = - createFloatingWorkspaceTourInteractionSnapshot(state) - rememberFloatingTerminalReturnFocus() - } else if (!resolvedOpen && floatingTerminalOpen) { - restoreFloatingTerminalReturnFocus() - } - setFloatingTerminalOpen(resolvedOpen) - // Why gated on the flag: `settings` is undefined until it hydrates, so the - // feature-off effect force-closes the panel on every boot. Persisting there would - // overwrite the user's restored `open` with a value they never chose. - if (floatingTerminalEnabled) { - persistFloatingTerminalPanelOpen(resolvedOpen) - } - }, - [ - floatingTerminalEnabled, - floatingTerminalOpen, - rememberFloatingTerminalReturnFocus, - restoreFloatingTerminalReturnFocus - ] - ) - - useEffect(() => { - const toggleFloatingTerminal = (): void => { - if (floatingTerminalEnabled) { - setFloatingTerminalOpenWithFocus((open) => !open) - } - } - window.addEventListener(TOGGLE_FLOATING_TERMINAL_EVENT, toggleFloatingTerminal) - return () => window.removeEventListener(TOGGLE_FLOATING_TERMINAL_EVENT, toggleFloatingTerminal) - }, [floatingTerminalEnabled, setFloatingTerminalOpenWithFocus]) - - useEffect(() => { - // Why the hydration gate: this effect fires on every boot while settings are still - // undefined, and closing there discards the restored open state before the real flag - // value arrives. Only a hydrated flag-off is an actual disable. - if (floatingTerminalSettingsHydrated && !floatingTerminalEnabled) { - setFloatingTerminalOpenWithFocus(false) - } - }, [floatingTerminalSettingsHydrated, floatingTerminalEnabled, setFloatingTerminalOpenWithFocus]) - - const sidebarWidth = useAppStore((s) => s.sidebarWidth) - const sidebarOpen = useAppStore((s) => s.sidebarOpen) - const groupBy = useAppStore((s) => s.groupBy) - const sortBy = useAppStore((s) => s.sortBy) - const projectOrderBy = useAppStore((s) => s.projectOrderBy) - const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) - const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) - const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) - const hideWorkspacesFromOtherDevices = useAppStore((s) => s.hideWorkspacesFromOtherDevices) - const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) - const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) - const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) - const showDotfilesByWorktree = useAppStore((s) => s.showDotfilesByWorktree) - const filterRepoIds = useAppStore((s) => s.filterRepoIds) - const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey) - const persistedUIReady = useAppStore((s) => s.persistedUIReady) - const shouldMountContextualTourOverlay = activeContextualTourId !== null - useOsc52ClipboardDefaultOnNotice(persistedUIReady) - const shouldMountSetupGuideTelemetryObserver = persistedUIReady - const shouldMountUpdateCard = shouldMountUpdateCardForStatus(updateStatus) - const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth) - const markdownTocPanelWidth = useAppStore((s) => s.markdownTocPanelWidth) - const combinedDiffFileTreeWidth = useAppStore((s) => s.combinedDiffFileTreeWidth) - const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen) - const rightSidebarTab = useAppStore((s) => s.rightSidebarTab) - const rightSidebarExplorerView = useAppStore((s) => s.rightSidebarExplorerView) - const isFullScreen = useAppStore((s) => s.isFullScreen) - const settings = useAppStore((s) => s.settings) - const systemPrefersDark = useSystemPrefersDark() - const leftSidebarStyle = useMemo( - () => resolveLeftSidebarStyleVariables(settings, systemPrefersDark), - [settings, systemPrefersDark] - ) as React.CSSProperties | undefined - const dictationState = useAppStore((s) => s.dictationState) - const hasSshCredentialRequest = useAppStore((s) => s.sshCredentialQueue.length > 0) - const shouldMountDictationController = - settings?.voice?.enabled === true || dictationState !== 'idle' - const primarySelectionMiddleClickPaste = resolvePrimarySelectionMiddleClickPaste( - settings?.primarySelectionMiddleClickPaste - ) - usePrimarySelectionPaste(primarySelectionMiddleClickPaste) - - useAppMenuPaste() - useAppMenuSelectionActions() - useLargeTextControlPaste() - const petEnabled = useAppStore((s) => s.settings?.experimentalPet === true) - const petVisible = useAppStore((s) => s.petVisible) - const renderPetOverlay = shouldRenderPetOverlay({ - persistedUIReady, - petEnabled, - petVisible - }) - const canGoBackWorktree = useAppStore(canGoBackWorktreeHistory) - const canGoForwardWorktree = useAppStore(canGoForwardWorktreeHistory) - const titlebarLeftControlsRef = useRef(null) - const [collapsedSidebarHeaderWidth, setCollapsedSidebarHeaderWidth] = useState(0) - const [mountedLazyModalIds, setMountedLazyModalIds] = useState>(() => new Set()) - const [shouldMountAddRepoDialog, setShouldMountAddRepoDialog] = useState(false) - const [onboarding, setOnboarding] = useState(null) - const [onboardingLoaded, setOnboardingLoaded] = useState(false) - const featureTipsPromptedThisSessionRef = useRef(false) - const featureTipsSuppressedByOnboardingThisSessionRef = useRef(false) - const unmountAddRepoDialogTimerRef = useRef | null>(null) - const [featureTipCliInstalled, setFeatureTipCliInstalled] = useState(null) - const shouldRenderOnboarding = onboarding !== null && shouldShowOnboarding(onboarding) - - useEffect(() => { - if (activeModal === 'add-repo') { - if (unmountAddRepoDialogTimerRef.current) { - clearTimeout(unmountAddRepoDialogTimerRef.current) - unmountAddRepoDialogTimerRef.current = null - } - setShouldMountAddRepoDialog(true) - return - } - if (shouldMountAddRepoDialog && !unmountAddRepoDialogTimerRef.current) { - // Why: AddRepoDialog's close effect aborts in-flight clone work; keep one closed render before unmounting hidden SSH/remote subscriptions. - unmountAddRepoDialogTimerRef.current = setTimeout(() => { - setShouldMountAddRepoDialog(false) - unmountAddRepoDialogTimerRef.current = null - }, 0) - } - return () => { - if (unmountAddRepoDialogTimerRef.current) { - clearTimeout(unmountAddRepoDialogTimerRef.current) - unmountAddRepoDialogTimerRef.current = null - } - } - }, [activeModal, shouldMountAddRepoDialog]) - - // Subscribe to IPC push events - useIpcEvents() - useRemoteRuntimeRecoveryTriggers() - useAutomationDispatchEvents() - // Why: retention runs at App level (in , a null leaf) so "done" agents survive card collapse and its high-churn subscriptions don't re-render App. - // Why: git polling lives at App level (RightSidebar unmounts when closed, stranding stale Rebasing/Merging badges); gate on workspaceSessionReady so it doesn't compete with first paint. - useGitStatusPolling({ enabled: workspaceSessionReady }) - // Why: wire file-change watching at App level so the editor keeps hearing FS changes when Explorer unmounts (right-sidebar switches to Source Control/Checks). - useEditorExternalWatch() - useGlobalFileDrop() - useAutoAckViewedAgent() - useEffect(() => { - return onOnboardingReopened(setOnboarding) - }, []) - - useEffect(() => { - // Why: suppress tours until onboarding state is known (null = loading) so a first-run user can't mark a tour seen before onboarding appears. - const suppressTours = !onboardingLoaded || shouldShowOnboarding(onboarding) - actions.setContextualToursOnboardingVisible(suppressTours) - }, [actions, onboarding, onboardingLoaded]) - - useEffect(() => { - if (!persistedUIReady || !onboardingLoaded || contextualToursAutoEligible !== null) { - return - } - // Why: rollout targets first-run onboarding users; existing profiles are classified once and never auto-toured. - actions.setContextualToursAutoEligible(shouldShowOnboarding(onboarding)) - }, [actions, contextualToursAutoEligible, onboarding, onboardingLoaded, persistedUIReady]) - - useEffect(() => { - if (!persistedUIReady) { - return - } - - let cancelled = false - void window.api.cli - .getInstallStatus() - .then((status) => { - if (cancelled) { - return - } - setFeatureTipCliInstalled(isCliFeatureTipCompleted(status)) - }) - .catch(() => { - if (!cancelled) { - setFeatureTipCliInstalled(true) - } - }) - - return () => { - cancelled = true - } - }, [persistedUIReady]) - - useEffect(() => { - const featureTipsDecision = getFeatureTipsAppOpenDecision({ - activeModal, - cliInstalled: featureTipCliInstalled, - featureTipsSeenIds, - featureInteractions, - onboarding, - persistedUIReady, - promptedThisSession: featureTipsPromptedThisSessionRef.current, - settings, - suppressedByOnboardingThisSession: featureTipsSuppressedByOnboardingThisSessionRef.current - }) - - if (featureTipsDecision.kind === 'suppress-for-onboarding') { - // Why: first-run users should finish onboarding without a second education modal in the same session. - featureTipsSuppressedByOnboardingThisSessionRef.current = true - return - } - - if (featureTipsDecision.kind !== 'open') { - return - } - - featureTipsPromptedThisSessionRef.current = true - if (featureTipsDecision.tipId === 'orca-cli') { - trackOrcaCliFeatureTipShown('app_open') - } else if (featureTipsDecision.tipId === 'cmd-j-palette') { - trackCmdJPaletteFeatureTipShown('app_open') - } - // Why: mark seen on show so a quit/crash before dismiss doesn't reappear it next launch. - actions.markFeatureTipsSeen([featureTipsDecision.tipId]) - actions.openModal('feature-tips', { source: 'app_open', tipId: featureTipsDecision.tipId }) - }, [ - activeModal, - actions, - featureTipCliInstalled, - featureInteractions, - featureTipsSeenIds, - onboarding, - persistedUIReady, - settings - ]) - - // Why: useLayoutEffect fires before paint, so dispatching SYNC_FIT_PANES_EVENT reflows the terminal in the same frame as the width change — no wrongly-sized transient. - useLayoutEffect(() => { - window.dispatchEvent(new CustomEvent(SYNC_FIT_PANES_EVENT)) - }, [sidebarOpen, rightSidebarOpen]) - - // Fetch initial data + hydrate GitHub cache from disk - useEffect(() => { - let cancelled = false - // Why: declared outside the async block so cleanup can abort it — under StrictMode the first (unmounted) pass would otherwise keep spawning PTYs. - const abortController = new AbortController() - - // Why (issue #1158): hydrate persisted UI right after ui.get() succeeds; the UI writer is gated only on persistedUIReady, so later default fallback would serialize defaults to disk. - let uiHydrated = false - // Why (issue #1158): track whether success-path reconnect started so the catch doesn't re-run it — re-entering on partially-mutated state would double-set ptyIds and drain pending* twice. - let reconnectStarted = false - void (async () => { - const startupStartedAt = performance.now() - logRendererStartupDiagnostic('startup-chain-start') - try { - // Why: nothing in the hydration chain reads profile state synchronously, so don't let it add a serial IPC round-trip before fetchSettings. - void actions.fetchOrcaProfiles() - // Why: repo/worktree hydration routes through settings.activeRuntimeEnvironmentId; load settings first so a persisted remote runtime doesn't hydrate stale local state. - await timeRendererStartupStep('fetch-settings', () => actions.fetchSettings()) - // Why: hidden-at-launch PTYs can query OSC 10/11 before any pane mounts; publish view attributes as soon as settings exist so main's silent-until-push responder has data. - publishTerminalViewAttributesAtAppStart( - useAppStore.getState().settings, - getSystemPrefersDark() - ) - // Why: start keybindings + onboarding now so their IPC overlaps the local catalog scans; await them at their original spots. The .catch marks rejections handled if an earlier await throws first. - // Why: browser session profiles are NOT started early — on a remote runtime the RPC may be unconnected and a failed fetch clears the list. - const keybindingsPromise = timeRendererStartupStep('fetch-keybindings', () => - actions.fetchKeybindings() - ) - keybindingsPromise.catch(() => {}) - const onboardingPromise = timeRendererStartupStep('onboarding-get', () => - window.api.onboarding.get() - ) - onboardingPromise.catch(() => {}) - // Why: await ui.get() (not overlap) so persisted view settings hydrate before the local catalog/session steps and first paint reflects them. - const persistedUI = await timeRendererStartupStep('ui-get', () => window.api.ui.get()) - uiHydrated = timeRendererStartupSyncStep('hydrate-persisted-ui', () => - hydratePersistedUIAfterStartupRead({ - persistedUI, - cancelled, - hydratePersistedUI: actions.hydratePersistedUI - }) - ) - // Why: list-runtime-session-hosts reads no repo state, so overlap it with the repo scan - // instead of paying its IPC round-trip serially before repos. .catch marks rejections handled - // if an earlier await throws first; the value is awaited below and surfaces any error there. - const runtimeHostsPromise = timeRendererStartupStep( - 'list-runtime-session-hosts', - listRuntimeSessionHostIdsForStartup - ) - runtimeHostsPromise.catch(() => {}) - // Why: saved remote runtimes can spend the full connect timeout; load only the local catalog for first paint and refresh remotes after hydration. - await timeRendererStartupStep('fetch-repos-local', () => - actions.fetchReposForAllHosts({ remoteHosts: 'skip' }) - ) - await timeRendererStartupStep('repo-catalog-settlement', () => - actions.awaitLocalRepoCatalogSettlement() - ) - // Why: folder workspaces merge against projectGroups (repos.ts fetchFolderWorkspacesForAllHosts), - // so keep this chain ordered while overlapping it with session-scoped hydration. - const localCatalogChain = (async () => { - await timeRendererStartupStep('fetch-project-groups-local', () => - actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' }) - ) - await timeRendererStartupStep('fetch-folder-workspaces-local', () => - actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' }) - ) - })() - const sessionReadPromise = runtimeHostsPromise.then((startupRuntimeHostIds) => - // Why: include saved runtime host ids so per-host worktree session slices restore from local settings without waiting on network reachability; unreadable partitions skip. - timeRendererStartupStep('session-get', () => - fetchWorkspaceSessionWithRuntimeHostOwners( - window.api.session, - useAppStore.getState().repos, - startupRuntimeHostIds - ) - ) - ) - const hydrationSessionChain = sessionReadPromise.then(async (sessionRead) => { - const hydrationRepoIds = collectWorktreeHydrationRepoIdsFromSession( - sessionRead.session, - sessionRead.runtimeHostIdByWorkspaceSessionKey - ) - const hydrationRepoIdSet = new Set(hydrationRepoIds) - const hydrationRepos = useAppStore.getState().repos.filter( - (repo) => - hydrationRepoIdSet.has(repo.id) && - // Why: disconnected SSH repos hydrate from local metadata; only runtime-owned repos use placeholders. - parseExecutionHostId(getRepoExecutionHostId(repo))?.kind !== 'runtime' - ) - // Why: worktree refresh can spawn host Git; wait for main's shell-PATH generation fence first. - await timeRendererStartupStep('first-window-services-await', () => - window.api.app.awaitFirstWindowStartupServices() - ) - await timeRendererStartupStep('fetch-hydration-worktrees', () => - mapWithConcurrency(hydrationRepos, WORKTREE_REFRESH_CONCURRENCY, (repo) => - actions.fetchWorktrees(repo.id, { executionHostId: getRepoExecutionHostId(repo) }) - ) - ) - return sessionRead - }) - // Why: wait for both writers to settle before recovery so neither can mutate hydrated state afterward. - const [sessionOutcome, catalogOutcome] = await Promise.allSettled([ - hydrationSessionChain, - localCatalogChain - ]) - if (sessionOutcome.status === 'rejected') { - throw sessionOutcome.reason - } - if (catalogOutcome.status === 'rejected') { - throw catalogOutcome.reason - } - const sessionRead = sessionOutcome.value - await keybindingsPromise - await timeRendererStartupStep('repo-catalog-final-settlement', () => - actions.awaitLocalRepoCatalogSettlement() - ) - if (!cancelled) { - const sessionHydrationOptions = { - additionalValidWorkspaceKeys: collectFolderWorkspaceKeysFromSession(sessionRead.session) - } - timeRendererStartupSyncStep('hydrate-session-stores', () => { - actions.hydrateWorkspaceSession(sessionRead.session, { - ...sessionHydrationOptions, - runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey - }) - actions.hydrateTabsSession(sessionRead.session, sessionHydrationOptions) - actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions) - actions.hydrateBrowserSession(sessionRead.session, sessionHydrationOptions) - }) - // Why: prune visit timestamps AFTER hydration (earlier, worktreesByRepo may be empty and prune would drop entries for worktrees about to appear); seed the active worktree if missing. - // See docs/cmd-j-empty-query-ordering.md. - timeRendererStartupSyncStep('visit-timestamp-prune', () => { - actions.pruneLastVisitedTimestamps() - actions.seedActiveWorktreeLastVisitedIfMissing() - }) - await timeRendererStartupStep('fetch-browser-session-profiles', () => - actions.fetchBrowserSessionProfiles() - ) - const onboardingState = await onboardingPromise - if (!cancelled) { - setOnboarding(onboardingState) - setOnboardingLoaded(true) - } - - // Why: re-establish SSH before terminal reconnect so SSH-backed tabs route through pty.attach; passphrase targets defer to tab focus to avoid stacked credential dialogs. - // Why: never dial runtime-owned (ephemeral-VM) targets from the renderer — ssh.connect would dispose the runtime layer's live relay session; filter them out here too. - const connectionIds = (sessionRead.session.activeConnectionIdsAtShutdown ?? []).filter( - (targetId) => !isRuntimeOwnedSshTargetId(targetId) - ) - if (connectionIds.length > 0) { - try { - const SSH_RECONNECT_TIMEOUT_MS = 15_000 - const allTargets = await timeRendererStartupStep('ssh-list-targets', () => - window.api.ssh.listTargets() - ) - const targetMap = new Map(allTargets.map((t) => [t.id, t])) - const targets = connectionIds.map((targetId) => ({ - targetId, - needsPassphrase: targetMap.get(targetId)?.lastRequiredPassphrase ?? false - })) - - const eagerTargets = targets.filter((t) => !t.needsPassphrase) - const deferredTargets = targets.filter((t) => t.needsPassphrase) - - if (deferredTargets.length > 0) { - actions.setDeferredSshReconnectTargets(deferredTargets.map((t) => t.targetId)) - } - - // Why: treat timed-out eager targets as deferred so their PTYs reattach on tab focus (ssh.connect keeps running in main and likely finishes by then). - const timedOutTargets: string[] = [] - await timeRendererStartupStep( - 'ssh-reconnect', - () => - Promise.all( - eagerTargets.map(async ({ targetId }) => { - const result = await reconnectSshTargetForRendererStartup({ - targetId, - timeoutMs: SSH_RECONNECT_TIMEOUT_MS, - connect: (id) => window.api.ssh.connect({ targetId: id }), - publishState: actions.setSshConnectionState, - onFailure: (id, error) => { - console.warn(`SSH auto-reconnect failed for ${id}:`, error) - } - }) - if (result.timedOut) { - timedOutTargets.push(targetId) - } - }) - ), - { - eagerTargets: eagerTargets.length, - deferredTargets: deferredTargets.length - } - ) - if (timedOutTargets.length > 0) { - actions.setDeferredSshReconnectTargets([ - ...deferredTargets.map((t) => t.targetId), - ...timedOutTargets - ]) - } - - // Why: older/wrapped providers may return no state from connect; poll main once as a compatibility fallback before terminal restoration. - for (const { targetId } of eagerTargets) { - if (timedOutTargets.includes(targetId)) { - continue - } - try { - const state = await window.api.ssh.getState({ targetId }) - console.warn( - `[ssh-restore] Polled state for ${targetId}: status=${state?.status}` - ) - if (state?.status === 'connected') { - actions.setSshConnectionState(targetId, state) - } - } catch { - /* best-effort */ - } - } - } catch (err) { - console.warn('SSH startup reconnect failed:', err) - } - } else { - logRendererStartupDiagnostic('ssh-reconnect-skipped', { connectionIds: 0 }) - } - - // first-window-services-await already fenced worktree hydration; terminal recovery reuses that ready state. - await timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect', () => - window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() - ) - await timeRendererStartupStep('terminal-provider-snapshot-capabilities', () => { - return refreshTerminalProviderSnapshotCapabilities( - collectTerminalProviderSnapshotPtyIds(useAppStore.getState()) - ) - }) - reconnectStarted = true - await timeRendererStartupStep('reconnect-terminals', () => - actions.reconnectPersistedTerminals(abortController.signal) - ) - await timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect', () => - window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() - ) - // Why here: reconnect just published restored PTY ids; sweeping them now - // re-offers stale Codex panes whose tabs never mount this session. - sweepRestoredCodexPanesForStaleAccounts(useAppStore.getState()) - syncZoomCSSVar() - // Why (issue #1158): unlock the session writer only after hydration and all dependent steps succeeded, so a mid-startup throw can't serialize partially-mutated state to disk. - actions.setHydrationSucceeded(true) - logRendererStartupDiagnostic('startup-hydration-done', { - durationMs: Math.round(performance.now() - startupStartedAt) - }) - void (async () => { - try { - try { - await timeRendererStartupStep('remote-catalog-refresh', async () => { - await actions.fetchReposForAllHosts() - await actions.fetchProjectGroupsForAllHosts() - await actions.fetchFolderWorkspacesForAllHosts() - }) - } catch (err) { - console.warn('Remote startup catalog refresh failed:', err) - } - if (!cancelled) { - try { - await timeRendererStartupStep('remote-worktree-refresh', async () => { - // Why: the full scan is not required for session recovery, so keep it off the startup-critical path. - await actions.fetchAllWorktrees() - // Why: the startup prune only saw session-referenced repos; use the deferred scan's - // authoritative results to drop deleted-worktree visit timestamps that would - // otherwise accumulate unbounded (disconnected SSH stays non-authoritative and is kept). - actions.pruneLastVisitedTimestamps() - await actions.fetchWorktreeLineage() - }) - } catch (err) { - console.warn('Deferred startup worktree refresh failed:', err) - } - } - } finally { - if (!cancelled) { - useAppStore.setState({ startupWorktreeRefreshCompleted: true }) - } - } - })() - } - } catch (error) { - // Why (issue #1158): leave in-memory state untouched and keep hydrationSucceeded false (default-hydrating here once erased saved tabs); still flip the ready flags so the UI mounts. - const stepLabel = error instanceof Error && error.message ? error.message : String(error) - console.error( - '[startup] Workspace session hydration failed; leaving disk state untouched:', - stepLabel, - error - ) - if (!cancelled) { - // Why: degraded mode stays interactive; later repo/runtime changes must not remain gated forever. - useAppStore.setState({ startupWorktreeRefreshCompleted: true }) - // Why (issue #1158): only apply default UI if ui.get() never hydrated; otherwise defaults would clobber ui.json via the debounced writer. - const fallbackUI = getStartupErrorFallbackUI(uiHydrated) - if (fallbackUI) { - actions.hydratePersistedUI(fallbackUI, 'startup') - } - // Why (issue #1158): sticky toast so the user knows they're in degraded "no-save" mode (hydrationSucceeded stays false); "Restart now" calls app.relaunch to recover. - toast.error(translate('auto.App.12e77cf12b', 'Session restore failed'), { - description: translate( - 'auto.App.0a9e810705', - "Changes won't be saved until restart. Your previous tabs are safe on disk." - ), - duration: Infinity, - dismissible: true, - action: { - label: translate('auto.App.caea5b51b9', 'Restart now'), - onClick: () => { - void window.api.app.relaunch() - } - } - }) - // Why: reconnect flips workspaceSessionReady so the UI mounts, but hydrationSucceeded stays false so the session writer can't overwrite the file we failed to load. - if (!reconnectStarted) { - try { - await window.api.app.awaitFirstWindowStartupServices() - await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() - await refreshTerminalProviderSnapshotCapabilities( - collectTerminalProviderSnapshotPtyIds(useAppStore.getState()) - ) - await actions.reconnectPersistedTerminals(abortController.signal) - await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() - } catch (reconnectErr) { - console.error( - '[startup] reconnectPersistedTerminals failed in error path:', - reconnectErr - ) - // Why (issue #1158): the await may have run during StrictMode teardown; re-check !cancelled so a cancelled pass 1 doesn't stomp pass 2's hydration. - if (!cancelled) { - // Why (issue #1158): recovery threw too; force the flag so the shell still mounts, and clear pending* maps (normally drained by reconnect) to avoid phantom reconnects on dead PTYs. - useAppStore.setState({ - workspaceSessionReady: true, - pendingReconnectWorktreeIds: [], - pendingReconnectTabByWorktree: {}, - pendingReconnectPtyIdByTabId: {} - }) - } - } - } else { - // Why (issue #1158): reconnect already started; re-running over its partially-mutated state would double-set ptyIds and drain pending* twice — force the flag, clear pending*. - useAppStore.setState({ - workspaceSessionReady: true, - pendingReconnectWorktreeIds: [], - pendingReconnectTabByWorktree: {}, - pendingReconnectPtyIdByTabId: {} - }) - } - } - } - void actions.initGitHubCache() - })() - - return () => { - cancelled = true - abortController.abort() - } - }, [actions]) - - useEffect(() => { - setRuntimeGraphStoreStateGetter(useAppStore.getState) - return () => { - setRuntimeGraphStoreStateGetter(null) - } - }, []) - - useEffect(() => installCodexDetachedPaneRestartExecutor(), []) - - useEffect(() => { - let previousKey = getRuntimeMobileSessionSyncKey(useAppStore.getState()) - return useAppStore.subscribe((state, previousState) => { - // Why: this fires on every store mutation; read the cached prefers-dark snapshot instead of allocating a throwaway MediaQueryList via matchMedia each tick. - const systemPrefersDark = getSystemPrefersDarkSnapshot() - // Why: skip the key build when every input is reference-unchanged; the gate mirrors every field getRuntimeMobileSessionSyncKey uses. - if ( - canSkipRuntimeMobileSessionSyncKeyBuild( - state, - previousState, - systemPrefersDark, - previousKey.systemPrefersDark - ) - ) { - return - } - const nextKey = getRuntimeMobileSessionSyncKey( - state, - previousState, - previousKey, - systemPrefersDark - ) - if (runtimeMobileSessionSyncKeysEqual(nextKey, previousKey)) { - return - } - previousKey = nextKey - scheduleRuntimeGraphSync() - }) - }, []) - - useEffect(() => registerUpdaterBeforeUnloadBypass(), []) - - useEffect(() => { - setRuntimeGraphSyncEnabled(workspaceSessionReady) - return () => { - setRuntimeGraphSyncEnabled(false) - } - }, [workspaceSessionReady]) - - // Why: session persistence only writes to disk; a Zustand subscribe() outside React drops ~15 render-cycle subscriptions and their re-renders on every tab/file/browser change. - useEffect(() => { - return createSessionWriteSubscriber({ - store: useAppStore, - shouldSchedulePersist: () => !isRemoteWorkspaceSnapshotApplyInProgress(), - persist: ({ patch }) => { - const state = useAppStore.getState() - // Why: route each host's worktree-scoped slice to its own partition; return the local write so the remote-workspace upload chain below keeps its ordering. - const localWrite = patchWorkspaceSessionByHost(window.api.session, patch, state) - void localWrite - const hydratedTargetIds = Array.from(state.remoteWorkspaceHydratedTargetIds).filter( - (targetId) => state.remoteWorkspaceSyncStatusByTargetId[targetId]?.phase !== 'conflict' - ) - if (hydratedTargetIds.length > 0) { - void localWrite - .then(() => window.api.remoteWorkspace?.setForConnectedTargets({ hydratedTargetIds })) - .then((results) => { - for (const { targetId, result } of results ?? []) { - applyRemoteWorkspacePatchStatus(targetId, result) - } - }) - .catch((err) => { - for (const targetId of hydratedTargetIds) { - useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, { - phase: 'error', - direction: 'push', - message: err instanceof Error ? err.message : 'Workspace upload failed' - }) - } - }) - } - } - }) - }, []) - - // On shutdown, capture terminal scrollback buffers and flush all durable - // renderer state through one synchronous main-process checkpoint. - useEffect(() => { - // Why: beforeunload fires twice during a manual quit — once from the - // synthetic dispatch in the onWindowCloseRequested handler (captures - // good data while TerminalPanes are still mounted), and again from the - // native window close triggered by confirmWindowClose(). Between these - // two firings, PTY exit events can arrive and unmount TerminalPanes, - // emptying shutdownBufferCaptures. The guard prevents the second call - // from overwriting the good session data with an empty snapshot. - const shutdownCheckpoint = createShutdownCheckpointGuard(() => { - const shouldCaptureSession = shouldPersistWorkspaceSession(useAppStore.getState()) - if (shouldCaptureSession) { - for (const capture of shutdownBufferCaptures.values()) { - try { - capture({ includeLocalBuffers: false }) - } catch { - // Don't let one pane's failure block the rest. - } - } - // Why: agent provider session ids live only in agentStatusByPaneKey, - // which is in-memory. Capture them into the persisted sleeping-session - // map so a daemon/session death while the app is closed can still - // cold-restore via the agent's resume command (#5232). - useAppStore.getState().captureAllSleepingAgentSessions('quit') - } - // Why: re-read state after capture() calls populated scrollback buffers - // into the store via Zustand setters. The earlier read is only for the - // gating flags and would miss those updates. - const freshState = useAppStore.getState() - let sessionSnapshots: ReturnType = [] - try { - sessionSnapshots = shouldCaptureSession - ? buildWorkspaceSessionHostSnapshots(buildWorkspaceSessionPayload(freshState), freshState) - : [] - } catch (error) { - // Why: dirty drafts exist only in the full session snapshot. - if ( - !isIntentionalAppRestartInProgress() || - freshState.openFiles.some((file) => file.isDirty) - ) { - throw error - } - console.error('[app] Full renderer session snapshot failed; using durable session', error) - window.api.app.stageBeforeUnloadSync({ - sessions: [], - ui: buildActiveViewUnloadPatch(freshState) - }) - return - } - window.api.app.stageBeforeUnloadSync({ - sessions: sessionSnapshots, - ui: buildActiveViewUnloadPatch(freshState) - }) - }) - const persistBeforeUnload = createShutdownCheckpointBeforeUnloadHandler(shutdownCheckpoint) - window.addEventListener('beforeunload', persistBeforeUnload) - window.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, shutdownCheckpoint.reset) - window.addEventListener(ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, shutdownCheckpoint.reset) - window.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, shutdownCheckpoint.reset) - return () => { - window.removeEventListener('beforeunload', persistBeforeUnload) - window.removeEventListener(ORCA_APP_RESTART_ABORTED_EVENT, shutdownCheckpoint.reset) - window.removeEventListener( - ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, - shutdownCheckpoint.reset - ) - window.removeEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, shutdownCheckpoint.reset) - } - }, []) - - // Why: beforeunload never fires on a hard kill (crash, forced update, TerminateProcess), so periodically capture agent session ids (not scrollback) so live agents keep a resume record. - useEffect(() => { - const timer = window.setInterval(() => { - if (!shouldPersistWorkspaceSession(useAppStore.getState())) { - return - } - useAppStore.getState().captureAllSleepingAgentSessions('periodic') - }, SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS) - return () => window.clearInterval(timer) - }, []) - - // Why: subscribe at the always-mounted App root — Terminal owns the confirm flow but isn't mounted on the landing page, so subscribing there left File→Exit / Ctrl+Q with no listener (#5144). - useEffect(() => { - return window.api.ui.onWindowCloseRequested(dispatchWindowCloseRequest) - }, []) - - // Why no periodic scrollback save: the old 3-min re-serialize (#461) stalled the main thread for seconds; the out-of-process daemon (#729) is the durable replacement, non-daemon users lose in-session scrollback on unexpected exit. - - useEffect(() => { - if (!persistedUIReady) { - return - } - - const timer = window.setTimeout(() => { - void window.api.ui.set({ - sidebarWidth, - rightSidebarOpen, - rightSidebarTab, - rightSidebarExplorerView, - rightSidebarWidth, - markdownTocPanelWidth, - combinedDiffFileTreeWidth, - groupBy, - sortBy, - projectOrderBy, - showActiveOnly: false, - hideSleepingWorkspaces: !showSleepingWorkspaces, - showSleepingWorkspaces, - hideDefaultBranchWorkspace, - hideAutomationGeneratedWorkspaces, - hideCliCreatedWorkspaces, - hideDetachedHeadWorkspaces, - hideWorkspacesFromOtherDevices, - alwaysShowDefaultBranchWorkspace, - showDotfilesByWorktree, - // Why: the store keeps this readonly for identity stability, but PersistedUI crosses to - // main, which owns a mutable array — copy at the boundary rather than widening the wire type. - filterRepoIds: [...filterRepoIds], - // Why (#9002): activeView is deliberately NOT included here. It used to - // ride this same 150ms writer (#8265), which meant every top-level view - // switch scheduled a full durable-state save. The narrow preference - // effect below persists it without touching the recovery snapshot. - // Why: rides the same debounced save so dashboard auto-acks (which fire - // on focus/visibility) and the in-memory ack cleanup paths in - // agent-status.ts (close/dismiss) both flow to disk through map - // identity changes. Without persisting, agent rows that survive - // restart come back bold even when the user had already visited them. - acknowledgedAgentsByPaneKey - }) - }, 150) - - return () => window.clearTimeout(timer) - }, [ - persistedUIReady, - sidebarWidth, - rightSidebarOpen, - rightSidebarTab, - rightSidebarExplorerView, - rightSidebarWidth, - markdownTocPanelWidth, - combinedDiffFileTreeWidth, - groupBy, - sortBy, - projectOrderBy, - showSleepingWorkspaces, - hideDefaultBranchWorkspace, - hideAutomationGeneratedWorkspaces, - hideCliCreatedWorkspaces, - hideDetachedHeadWorkspaces, - hideWorkspacesFromOtherDevices, - alwaysShowDefaultBranchWorkspace, - showDotfilesByWorktree, - filterRepoIds, - acknowledgedAgentsByPaneKey - ]) - - // Why (#9002): activeView has its own tiny profile preference, so it can track - // every switch without scheduling the multi-MB durable-state writer. - useEffect(() => { - if (!persistedUIReady) { - return - } - void window.api.ui.set({ activeView }) - }, [activeView, persistedUIReady]) - - // Apply theme to document - useEffect(() => { - if (!settings) { - return - } - - if (settings.theme === 'dark') { - applyDocumentTheme('dark') - return undefined - } else if (settings.theme === 'light') { - applyDocumentTheme('light') - return undefined - } - // system - const mq = window.matchMedia('(prefers-color-scheme: dark)') - applyDocumentTheme('system') - const handler = (): void => { - applyDocumentTheme('system') - // System theme changes don't mutate the store, so mobile terminal colors need an explicit graph republish. - scheduleRuntimeGraphSync() - } - mq.addEventListener('change', handler) - return () => mq.removeEventListener('change', handler) - }, [settings]) - - useEffect(() => { - document.documentElement.style.setProperty( - '--app-font-family', - buildAppFontFamily(settings?.appFontFamily) - ) - }, [settings?.appFontFamily]) - - // Refresh GitHub data (PR/issue status) when window regains focus - useEffect(() => { - const handler = (): void => { - if (document.visibilityState === 'visible') { - actions.refreshAllGitHub() - actions.bumpGitHubPRVisibleRefreshGeneration() - } else { - actions.reportVisibleGitHubPRRefreshCandidates([], Date.now()) - } - } - document.addEventListener('visibilitychange', handler) - return () => document.removeEventListener('visibilitychange', handler) - }, [actions]) - - // Why (STA-2383): macOS throttles the backgrounded window; on occlusion-uncover only `focus` - // fires (invalidate-only), so the app-shell's dvh height stays stale and the bottom status bar - // is clipped off-screen until a manual resize. Relay the genuine hidden→visible reveal so main - // runs the same full repaint (size jiggle) that show/restore/resume get, recomputing the layout. - useEffect(() => { - if (!isMac || isPairedWebClientWindow()) { - return - } - const handler = (): void => { - if (document.visibilityState !== 'visible') { - return - } - window.api?.ui?.notifyWindowRevealed?.() - } - document.addEventListener('visibilitychange', handler) - return () => document.removeEventListener('visibilitychange', handler) - }, []) - - const hasTabBar = tabCount >= 2 - const showTitlebarExpandButton = workspaceChromeActive && !hasTabBar && effectiveActiveTabExpanded - // Activity/Space are full-page navigation surfaces (like Settings), so the worktree sidebar is hidden there. - const showSidebar = - activeView !== 'settings' && activeView !== 'activity' && activeView !== 'space' - // Tasks/Landing show the full titlebar only when the sidebar is collapsed; open, they mirror workspace view (creation suppresses it). - const stackedSidebarOpen = - !workspaceChromeActive && !creationLayoutActive && showSidebar && sidebarOpen - // Visible creation keeps only the top-left window chrome; tabs and right-sidebar chrome stay gated by workspaceChromeActive. - const leftTitlebarChromeLayout = resolveLeftTitlebarChromeLayout({ - workspaceChromeActive, - stackedSidebarOpen, - creationLayoutActive, - sidebarOpen - }) - // Full-page navigation surfaces own the whole content area, so suppress right-sidebar controls. - const showRightSidebarControls = !creationLayoutActive && canShowRightSidebarForView(activeView) - const handleToggleExpand = (): void => { - if (!effectiveActiveTabId) { - return - } - window.dispatchEvent( - new CustomEvent(TOGGLE_TERMINAL_PANE_EXPAND_EVENT, { - detail: { tabId: effectiveActiveTabId } - }) - ) - } - - const globalShortcutStateRef = useRef({ - activeView, - activeWorktreeId, - actions, - floatingTerminalEnabled, - floatingTerminalOpen, - floatingVisibleTabCount, - keybindings, - pluginCommands, - terminalShortcutPolicy: settings?.terminalShortcutPolicy, - setFloatingTerminalOpenWithFocus, - workspaceChromeActive, - creationLayoutActive - }) - // Window key listeners are global and long-lived: one registration, but the handler reads current shortcut state each key event. - globalShortcutStateRef.current = { - activeView, - activeWorktreeId, - actions, - floatingTerminalEnabled, - floatingTerminalOpen, - floatingVisibleTabCount, - keybindings, - pluginCommands, - terminalShortcutPolicy: settings?.terminalShortcutPolicy, - setFloatingTerminalOpenWithFocus, - workspaceChromeActive, - creationLayoutActive - } - - useEffect(() => { - const doubleTapDetector = new ModifierDoubleTapDetector() - - const createRegisteredCommandHandlers = ( - input?: ShortcutDispatchInput, - keybindingContext: KeybindingContext = 'app' - ): Map boolean> => { - const { - activeView, - activeWorktreeId, - actions, - floatingTerminalEnabled, - floatingTerminalOpen, - terminalShortcutPolicy, - keybindings, - setFloatingTerminalOpenWithFocus, - workspaceChromeActive, - creationLayoutActive - } = globalShortcutStateRef.current - const floatingWorkspaceFocused = isFloatingWorkspacePanelFocused() - const canRevealRightSidebar = !creationLayoutActive && canShowRightSidebarForView(activeView) - const claim = (actionId: KeybindingActionId, run: () => void): boolean => { - input?.preventDefault() - if ( - input && - keybindingContext === 'terminal' && - (terminalShortcutPolicy ?? 'orca-first') === 'orca-first' - ) { - showTerminalShortcutCaptureNotification({ - actionId, - platform: shortcutPlatform, - keybindings - }) - } - run() - return true - } - - return new Map boolean>([ - [ - 'worktree.history.back', - () => { - if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) { - return false - } - return claim('worktree.history.back', () => useAppStore.getState().goBackWorktree()) - } - ], - [ - 'worktree.history.forward', - () => { - if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) { - return false - } - return claim('worktree.history.forward', () => - useAppStore.getState().goForwardWorktree() - ) - } - ], - ['sidebar.left.toggle', () => claim('sidebar.left.toggle', () => actions.toggleSidebar())], - [ - 'sidebar.sleepingWorkspaces.toggle', - () => - claim('sidebar.sleepingWorkspaces.toggle', () => { - const store = useAppStore.getState() - const nextShowSleeping = !store.showSleepingWorkspaces - store.setShowSleepingWorkspaces(nextShowSleeping) - if (nextShowSleeping) { - store.setSidebarOpen(true) - } - }) - ], - [ - 'floatingWorkspace.maximize', - () => { - if (floatingTerminalOpen || !floatingTerminalEnabled) { - return false - } - return claim('floatingWorkspace.maximize', () => { - requestFloatingTerminalOpenMaximized() - setFloatingTerminalOpenWithFocus(true) - }) - } - ], - [ - 'tab.rename', - () => { - const store = useAppStore.getState() - if ( - !workspaceChromeActive || - floatingWorkspaceFocused || - store.activeTabType !== 'terminal' || - !store.activeTabId - ) { - return false - } - return claim('tab.rename', () => store.setRenamingTabId(store.activeTabId!)) - } - ], - [ - 'workspace.rename', - () => { - if (!workspaceChromeActive || floatingWorkspaceFocused || !activeWorktreeId) { - return false - } - return claim('workspace.rename', () => { - useAppStore.getState().setSidebarOpen(true) - requestScrollToCurrentWorkspaceRevealAndRename() - }) - } - ], - [ - 'workspace.openBoard', - () => { - if (activeView === 'settings') { - return false - } - return claim('workspace.openBoard', () => { - useAppStore.getState().setSidebarOpen(true) - window.dispatchEvent(new CustomEvent(TOGGLE_WORKSPACE_BOARD_EVENT)) - }) - } - ], - [ - 'view.tasks', - () => { - const store = useAppStore.getState() - if (activeView === 'settings' || !store.repos.some((repo) => isGitRepoKind(repo))) { - return false - } - return claim('view.tasks', () => store.openTaskPage()) - } - ], - [ - 'sidebar.right.toggle', - () => - canRevealRightSidebar - ? claim('sidebar.right.toggle', () => actions.toggleRightSidebar()) - : false - ], - [ - 'sidebar.explorer.toggle', - () => - canRevealRightSidebar - ? claim('sidebar.explorer.toggle', () => actions.showRightSidebarFiles()) - : false - ], - [ - 'sidebar.search.toggle', - () => - canRevealRightSidebar - ? claim('sidebar.search.toggle', () => actions.showRightSidebarSearch()) - : false - ], - [ - 'sidebar.sourceControl.toggle', - () => { - if (!canRevealRightSidebar || document.querySelector('[data-terminal-search-root]')) { - return false - } - return claim('sidebar.sourceControl.toggle', () => { - actions.setRightSidebarTab('source-control') - actions.setRightSidebarOpen(true) - }) - } - ], - [ - 'sidebar.checks.toggle', - () => - canRevealRightSidebar - ? claim('sidebar.checks.toggle', () => { - actions.setRightSidebarTab('checks') - actions.setRightSidebarOpen(true) - }) - : false - ], - [ - 'sidebar.ports.toggle', - () => - canRevealRightSidebar - ? claim('sidebar.ports.toggle', () => { - actions.setRightSidebarTab('ports') - actions.setRightSidebarOpen(true) - }) - : false - ] - ]) - } - - const unregisterAppCommandDispatcher = registerAppCommandDispatcher((actionId) => - (createRegisteredCommandHandlers().get(actionId) ?? (() => false))() - ) - - const dispatchShortcutInput = (input: ShortcutDispatchInput): void => { - const { - activeView, - activeWorktreeId, - actions, - floatingTerminalEnabled, - floatingTerminalOpen, - floatingVisibleTabCount, - keybindings, - pluginCommands, - terminalShortcutPolicy, - setFloatingTerminalOpenWithFocus, - creationLayoutActive - } = globalShortcutStateRef.current - - // Child handlers (e.g. terminal search) share this window capture phase and fire first; bail if they already preventDefault'd so both don't act. - if (input.defaultPrevented) { - return - } - // The Settings shortcut recorder captures existing shortcuts, so global handlers must not fire while its button has focus. - if ( - input.target instanceof Element && - input.target.closest('[data-shortcut-recorder-active]') !== null - ) { - return - } - const context = getKeybindingContext(input.target) - - // Note: some shortcuts are also intercepted in createMainWindow.ts before-input-event (for browser-guest focus); the renderer keeps handlers for local focus. - - const matchShortcut = (actionId: KeybindingActionId): boolean => - keybindingMatchesAction(actionId, input, shortcutPlatform, keybindings, { - context, - terminalShortcutPolicy - }) - const notifyTerminalCapture = (actionId: KeybindingActionId): void => { - if (context !== 'terminal' || (terminalShortcutPolicy ?? 'orca-first') !== 'orca-first') { - return - } - showTerminalShortcutCaptureNotification({ - actionId, - platform: shortcutPlatform, - keybindings - }) - } - - const canRevealRightSidebar = !creationLayoutActive && canShowRightSidebarForView(activeView) - - const openSearchSidebar = (query: string | null): void => { - actions.showRightSidebarSearch(query ? { query } : undefined) - } - - if (matchShortcut('sidebar.search.toggle') && canRevealRightSidebar) { - // With a folder selected in the explorer, Cmd/Ctrl+Shift+F means "Find in Folder" — seed the include pattern with it, not a text search. - const selectedFolderRelativePath = - document.activeElement instanceof Element - ? selectedExplorerFolderRelativePath(document.activeElement) - : null - if (selectedFolderRelativePath !== null && activeWorktreeId) { - input.preventDefault() - notifyTerminalCapture('sidebar.search.toggle') - actions.showRightSidebarSearch({ - includePattern: folderRelativePathToIncludeGlob(selectedFolderRelativePath) - }) - return - } - - const selectedText = getSelectedTextForFileSearch() - if (selectedText) { - input.preventDefault() - notifyTerminalCapture('sidebar.search.toggle') - openSearchSidebar(selectedText) - return - } - } - - // An empty floating workspace has no tab to close, so Cmd/Ctrl+W hides the overlay before other surfaces act. - if ( - keybindingMatchesAction('tab.close', input, shortcutPlatform, keybindings, { - context: 'app' - }) && - shouldMinimizeFloatingWorkspacePanelOnCloseShortcut({ - floatingTerminalOpen, - floatingVisibleTabCount - }) - ) { - input.preventDefault() - setFloatingTerminalOpenWithFocus(false) - return - } - - // Floating panel closed → its keydown handler is gone, so honor the maximize chord here by opening it pre-maximized (no-op while it's open). - if ( - !floatingTerminalOpen && - matchShortcut('floatingWorkspace.maximize') && - floatingTerminalEnabled - ) { - input.preventDefault() - requestFloatingTerminalOpenMaximized() - setFloatingTerminalOpenWithFocus(true) - return - } - - // Skip editable surfaces so TipTap's Cmd+B bold works; this renderer-side fallback covers the blur→press IPC race (docs/markdown-cmd-b-bold-design.md). - if (isEditableTarget(input.target)) { - return - } - - // Let floating-terminal SSH/tmux control chords reach the terminal (xterm's helper textarea isn't a generic editable target). - if (isFloatingWorkspaceTerminalInputTarget(input.target)) { - return - } - - // Only short-circuit chords the floating panel itself claims; suppressing others here would silently no-op them when focus is in the panel. - const floatingWorkspaceFocused = isFloatingWorkspacePanelFocused() - if (floatingWorkspaceFocused) { - const floatingMatchOptions: KeybindingMatchOptions = { context, terminalShortcutPolicy } - if ( - matchFloatingWorkspacePanelChord( - input, - shortcutPlatform, - null, - keybindings, - floatingMatchOptions - ) !== null - ) { - return - } - } - - // Plugin chords are user-reviewed instructional content. They win over - // built-in defaults only in app focus; terminal/editor/browser handlers - // retain their own shortcut authority. - if (context === 'app') { - const pluginCommand = findPluginCommandForKeybinding( - pluginCommands, - input, - shortcutPlatform, - keybindings, - Boolean(activeWorktreeId) - ) - if (pluginCommand) { - input.preventDefault() - void executePluginCommand(pluginCommand, 'plugin-keybinding').catch(() => { - toast.error( - translate('auto.App.pluginCommandFailed', 'Could not run the plugin command.') - ) - }) - return - } - } - - const handlers = createRegisteredCommandHandlers(input, context) - for (const actionId of PLUGIN_COMMAND_ALIAS_ACTION_IDS) { - if (matchShortcut(actionId) && handlers.get(actionId)?.()) { - return - } - } - - // Unbound by default, so it runs after the built-in alias handlers above; only consumes the chord when the active worktree has unsent notes. - if (canRevealRightSidebar && matchShortcut('sourceControl.sendReviewNotes')) { - if (actions.openDiffNotesSendMenuForActiveWorktree()) { - input.preventDefault() - notifyTerminalCapture('sourceControl.sendReviewNotes') - } - } - } - - const onKeyDown = (e: KeyboardEvent): void => { - const detected = doubleTapDetector.process( - toModifierDoubleTapEvent({ - type: 'keyDown', - code: e.code, - key: e.key, - shift: e.shiftKey, - control: e.ctrlKey, - alt: e.altKey, - meta: e.metaKey, - isAutoRepeat: e.repeat - }), - Date.now() - ) - if (e.repeat) { - return - } - if (detected) { - // Synthetic input: no key/modifier flags, so only DoubleTap bindings match. - dispatchShortcutInput({ - doubleTapModifier: detected.modifier, - target: e.target, - defaultPrevented: e.defaultPrevented, - preventDefault: () => e.preventDefault() - }) - return - } - dispatchShortcutInput({ - key: e.key, - code: e.code, - altKey: e.altKey, - metaKey: e.metaKey, - ctrlKey: e.ctrlKey, - shiftKey: e.shiftKey, - target: e.target, - defaultPrevented: e.defaultPrevented, - preventDefault: () => e.preventDefault() - }) - } - - const onKeyUp = (e: KeyboardEvent): void => { - doubleTapDetector.process( - toModifierDoubleTapEvent({ - type: 'keyUp', - code: e.code, - key: e.key, - shift: e.shiftKey, - control: e.ctrlKey, - alt: e.altKey, - meta: e.metaKey - }), - Date.now() - ) - } - - // Why: a window blur mid-gesture must not leave the detector armed. - const onBlur = (): void => doubleTapDetector.reset() - - window.addEventListener('keydown', onKeyDown, { capture: true }) - window.addEventListener('keyup', onKeyUp, { capture: true }) - window.addEventListener('blur', onBlur) - return () => { - unregisterAppCommandDispatcher() - window.removeEventListener('keydown', onKeyDown, { capture: true }) - window.removeEventListener('keyup', onKeyUp, { capture: true }) - window.removeEventListener('blur', onBlur) - } - }, []) - - useLayoutEffect(() => { - const controls = titlebarLeftControlsRef.current - if (!controls) { - return - } - - const updateWidth = (): void => { - setCollapsedSidebarHeaderWidth(controls.getBoundingClientRect().width) - } - - updateWidth() - const observer = new ResizeObserver(() => { - updateWidth() - }) - observer.observe(controls) - return () => observer.disconnect() - }, [ - isFullScreen, - settings?.showTitlebarAppName, - showSidebar, - leftTitlebarChromeLayout.isFloating, - sidebarOpen - ]) - - const resolvedMountedLazyModalIds = resolveMountedLazyModalIds(activeModal, mountedLazyModalIds) - if (resolvedMountedLazyModalIds !== mountedLazyModalIds) { - // Why: lazy-load modals on first use, then keep them mounted so repeat opens preserve state and avoid re-fetch flashes. - setMountedLazyModalIds(new Set(resolvedMountedLazyModalIds)) - } - - // Why: extracted so the full-width titlebar and the sidebar-width left header share these controls without duplicating the agent badge popover. - const titlebarLeftControls = ( - // Why: measure the ENTIRE row so TabGroupPanel's collapse spacer reserves enough width; measuring only the inner cluster left back/forward over the first tab. - // Why: collapsed mode floats in a w-0 wrapper; w-max stops Windows Chromium from shrinking the app name to one glyph. -
-
- {isMac && !isFullScreen ? ( -
- ) : hasCustomTitleBar ? ( - /* Why: Windows/Linux remove the native title bar, so render the logo plus a ··· button that pops the application menu (as Alt does). */ - <> - - - - - - - {translate('auto.App.8b0b8eb54f', 'Application menu')} - - - - ) : ( -
- )} - {showSidebar && !hasCustomTitleBar && ( - <> - {settings?.showTitlebarAppName !== false && ( - - -
- - {translate('auto.App.5096cbbc86', 'Orca')} - -
-
- - { - void actions.updateSettings({ showTitlebarAppName: false }) - }} - > - {translate('auto.App.e81217c1b7', 'Hide App Name')} - - -
- )} - - )} - {showSidebar && ( - - - - - - {translate('auto.App.ce37cf5279', 'Toggle sidebar ({{value0}})', { - value0: leftSidebarShortcutLabel - })} - - - )} -
- {/* Why: Back/Forward span worktree + page history, so show the cluster wherever the shortcut is live (hidden in Settings/non-stack views). */} - {shouldShowWorktreeHistoryControls(activeView) && ( - // With the sidebar collapsed the header shrink-wraps and ml-auto has no spare width, so keep a fixed gutter before Back. -
- - - - - - {translate('auto.App.fe21e8f6f5', 'Go back ({{value0}})', { - value0: historyBackShortcutLabel - })} - - - - - - - - {translate('auto.App.f7aa73e785', 'Go forward ({{value0}})', { - value0: historyForwardShortcutLabel - })} - - -
- )} -
- ) - - const rightSidebarToggle = showRightSidebarControls ? ( - - - - - - {translate('auto.App.c184e056de', 'Toggle right sidebar ({{value0}})', { - value0: rightSidebarShortcutLabel - })} - - - ) : null - - const titlebarMainStrip = ( - <> - {activeView === 'activity' ? ( - - ) : creationLayoutActive ? null : ( -
- )} - {showTitlebarExpandButton && ( - - - - - - {translate('auto.App.c1cf0b0e4a', 'Collapse pane')} - - - )} - {/* Why: the open right sidebar's header renders its own close button, so hide this duplicate. */} - {!rightSidebarOpen && rightSidebarToggle} - {/* Why: reserve space so the Windows/Linux window-controls overlay doesn't obscure content. */} - {hasCustomTitleBar &&
} - - ) return (
- - {/* Why: plugin language-pack discovery must not re-render the App shell. */} - - {/* Why: leaf-mounted retention sync keeps agent-status subscriptions out of the App render tree. */} - - - {settings?.experimentalAgentDashboardPopout === true ? ( - - - - ) : null} - - {/* Why: workspace activation is a hot path; activeWorktreeId in reset keys would remount whole surfaces during wake. */} - -
- {/* Why: keep the non-workspace titlebar inside this left+center wrapper so it doesn't span over the right-sidebar column. */} -
- {/* Why: workspace view drops the full-width titlebar so tab groups extend to the top; settings/landing/tasks keep it. */} - {!leftTitlebarChromeLayout.shouldMount ? ( -
-
{titlebarLeftControls}
- {titlebarMainStrip} -
- ) : null} -
- {showSidebar ? ( - leftTitlebarChromeLayout.shouldMount ? ( - /* Why: when the sidebar is collapsed, take this titlebar-height header out of flex layout so the terminal/editor reclaim the left edge. */ -
-
- {titlebarLeftControls} -
-
- {/* Why: flex-1/min-h-0 slot needed under the fixed 36px header, else the sidebar collapses to content height and loses its scroll viewport. */} - - - -
-
- ) : ( - - - - ) - ) : null} -
- {/* Why: automations owns its page header; the stacked titlebar would be an empty 36px stripe. */} - {stackedSidebarOpen && activeView !== 'automations' ? ( -
{titlebarMainStrip}
- ) : null} -
- {/* Why: match the RightSidebar header's 36px/top-0 so the toggle's vertical center is identical open vs closed — else the icon jitters. */} - {workspaceChromeActive && !rightSidebarOpen && ( -
- {rightSidebarToggle} -
- )} -
- {shouldMountTerminalWorkbench ? ( -
- - - - - -
- ) : null} - - - {activeView === 'settings' ? : null} - {activeView === 'artifacts' ? : null} - {activeView === 'tasks' ? : null} - {activeView === 'automations' ? : null} - {activeView === 'activity' ? : null} - {activeView === 'space' ? : null} - {activeView === 'mobile' ? : null} - {activeView === 'terminal' && - creationLayoutActive && - activePendingCreationId ? ( - - ) : null} - {activeView === 'terminal' && - !activeWorktreeId && - !creationLayoutActive ? ( - - ) : null} - - -
- {showFloatingTerminalButton ? ( - setFloatingTerminalOpenWithFocus((open) => !open)} - /> - ) : null} -
-
-
-
- {/* Why: keep the shell mounted for layout stability (heavy panels disconnect while closed); unmount on the distraction-free tasks view. */} - {showRightSidebarControls ? ( - - - - ) : null} -
-
- {shouldMountFloatingTerminalPanel ? ( - - - - - - ) : null} - {statusBarVisible ? ( - - } - > - - - - - ) : null} - {/* Why: keep in the entry bundle so a stale/corrupt lazy chunk can't strand users at Create. */} - {activeModal === 'new-workspace-composer' ? ( - - - - ) : null} - - {shouldMountAddRepoDialog ? ( - - - - ) : null} - {/* Why: Settings can start Add Project without Sidebar, so its handoff dialogs must share the root host. */} - {activeModal === 'confirm-non-git-folder' ? ( - - - - ) : null} - {activeModal === 'confirm-add-project-from-folder' ? ( - - - - ) : null} - {activeModal === 'project-added' ? ( - - - - ) : null} - - {/* Why: root overlays can render Radix s; keep inside the shared provider so lazy surfaces mount from any entry point. */} - - {resolvedMountedLazyModalIds.has('workspace-cleanup') ? ( - - - - ) : null} - - - {resolvedMountedLazyModalIds.has('quick-open') ? ( - - - - ) : null} - {resolvedMountedLazyModalIds.has('worktree-palette') ? ( - - - - ) : null} - {resolvedMountedLazyModalIds.has('setup-guide') ? ( - - - - ) : null} - {resolvedMountedLazyModalIds.has('feature-wall') ? ( - - - - ) : null} - {resolvedMountedLazyModalIds.has('feature-tips') ? ( - - - - ) : null} - - {shouldMountSetupGuideTelemetryObserver ? ( - - - - ) : null} - {shouldMountContextualTourOverlay ? ( - - - - ) : null} - {/* Why: mount only after UI hydration, else a hidden pet flashes while the store still holds default visibility. */} - {renderPetOverlay ? ( - - - - - - ) : null} - {shouldMountUpdateCard ? ( - - - - - - ) : null} - - - - - - - - {/* Why: mount at App root to render once per session; internal cohort gate limits it to pre-telemetry users — see telemetry-plan.md §First-launch experience. */} - - - - - - - - {activeModal === 'delete-worktree' ? ( - - - - ) : null} - {activeModal === 'preserved-branch-review' ? ( - - - - ) : null} - - {hasSshCredentialRequest ? ( - - - - - - ) : null} - - - - - - - {onboarding && shouldRenderOnboarding ? ( - - - - - - ) : null} - {shouldMountDictationController ? ( - - - - - - ) : null} - - - - {/* Why: hosts a live terminal pane needing the link-routing preference context; mounting outside crashes it. */} - - - - - - - - + + +
diff --git a/src/renderer/src/app-shell/AppBackgroundServices.tsx b/src/renderer/src/app-shell/AppBackgroundServices.tsx new file mode 100644 index 00000000000..4740481e34f --- /dev/null +++ b/src/renderer/src/app-shell/AppBackgroundServices.tsx @@ -0,0 +1,38 @@ +import { Suspense } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' +import { AgentHibernationGate } from '../components/AgentHibernationGate' +import { AiVaultTabTitleSyncGate } from '../components/AiVaultTabTitleSyncGate' +import RetainedAgentsSyncGate from '../components/dashboard/RetainedAgentsSyncGate' +import { WorkspacePortScanner } from '../components/ports/WorkspacePortScanner' +import { MacosTccPromptNoticeHost } from '../hooks/MacosTccPromptNoticeHost' +import { useAppStore } from '../store' + +const DashboardPopoutBridge = lazy(() => import('../components/dashboard/DashboardPopoutBridge')) + +/** + * App-level gates that render nothing. Each lives here rather than inside the surface that + * needs it so its high-churn store subscriptions stay out of the App render tree. + */ +export function AppBackgroundServices(): React.JSX.Element { + const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) + const dashboardPopoutEnabled = useAppStore( + (s) => s.settings?.experimentalAgentDashboardPopout === true + ) + + return ( + <> + + {/* Why: plugin language-pack discovery must not re-render the App shell. */} + + {/* Why: leaf-mounted retention sync keeps agent-status subscriptions out of the App render tree. */} + + + {dashboardPopoutEnabled ? ( + + + + ) : null} + + + ) +} diff --git a/src/renderer/src/app-shell/AppRootSurfaces.tsx b/src/renderer/src/app-shell/AppRootSurfaces.tsx new file mode 100644 index 00000000000..793088a8be0 --- /dev/null +++ b/src/renderer/src/app-shell/AppRootSurfaces.tsx @@ -0,0 +1,368 @@ +import { Suspense } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' +import { translate } from '@/i18n/i18n' +import { RecoverableRenderErrorBoundary } from '../components/error-boundaries/RecoverableRenderErrorBoundary' +import NewWorkspaceComposerModal from '../components/NewWorkspaceComposerModal' +import { CrashReportDialog } from '../components/crash-report/CrashReportDialog' +import { MarkdownTemplatePicker } from '../components/editor/MarkdownTemplatePicker' +import RecentTabSwitcher from '../components/tab-bar/RecentTabSwitcher' +import { SkillFreshnessUpdateDialog } from '../components/skills/SkillFreshnessUpdateDialog' +import { StarNagCard } from '../components/StarNagCard' +import { StarNagAgentValueMomentObserver } from '../components/star-nag/StarNagAgentValueMomentObserver' +import { StarNagToastHost } from '../components/star-nag/StarNagToastHost' +import { TelemetryFirstLaunchSurface } from '../components/TelemetryFirstLaunchSurface' +import { ZoomOverlay } from '../components/ZoomOverlay' +import { shouldRenderPetOverlay } from '../components/pet/pet-overlay-visibility' +import { useAppStore } from '../store' +import type { UpdateStatus } from '../../../shared/update-status-types' +import { useLazyModalMounts } from './use-lazy-modal-mounts' +import type { FloatingWorkspacePanelState } from './use-floating-workspace-panel' +import type { OnboardingGate } from './use-onboarding-and-feature-tips' + +const QuickOpen = lazy(() => import('../components/QuickOpen')) +const WorktreeJumpPalette = lazy(() => import('../components/WorktreeJumpPalette')) +const WorkspaceCleanupDialog = lazy( + () => import('../components/workspace-cleanup/WorkspaceCleanupDialog') +) +const StatusBar = lazy(() => + import('../components/status-bar/StatusBar').then((module) => ({ default: module.StatusBar })) +) +const SetupGuideModal = lazy(() => import('../components/setup-guide/SetupGuideModal')) +const FeatureWallModal = lazy(() => import('../components/feature-wall/FeatureWallModal')) +const FeatureTipsModal = lazy(() => import('../components/feature-tips/FeatureTipsModal')) +const AddRepoDialog = lazy(() => import('../components/sidebar/AddRepoDialog')) +const NonGitFolderDialog = lazy(() => import('../components/sidebar/NonGitFolderDialog')) +const AddProjectFromFolderDialog = lazy( + () => import('../components/sidebar/AddProjectFromFolderDialog') +) +const ProjectAddedDialog = lazy(() => import('../components/sidebar/ProjectAddedDialog')) +const DeleteWorktreeDialog = lazy(() => import('../components/sidebar/DeleteWorktreeDialog')) +const PreservedBranchBatchReviewModal = lazy( + () => import('../components/sidebar/PreservedBranchBatchReviewModal') +) +const DictationController = lazy(() => + import('../components/dictation/DictationController').then((module) => ({ + default: module.DictationController + })) +) +const SshPassphraseDialog = lazy(() => + import('../components/settings/SshPassphraseDialog').then((module) => ({ + default: module.SshPassphraseDialog + })) +) +const UpdateCard = lazy(() => + import('../components/UpdateCard').then((module) => ({ default: module.UpdateCard })) +) +const RemoteServerUpdateDialog = lazy( + () => import('../components/settings/RemoteServerUpdateDialog') +) +const ContextualTourOverlay = lazy(() => + import('../components/contextual-tours/ContextualTourOverlay').then((module) => ({ + default: module.ContextualTourOverlay + })) +) +const SetupGuideTelemetryObserver = lazy(() => + import('../components/setup-guide/SetupGuideTelemetryObserver').then((module) => ({ + default: module.SetupGuideTelemetryObserver + })) +) +const FloatingTerminalPanel = lazy(() => + import('../components/floating-terminal/FloatingTerminalPanel').then((module) => ({ + default: module.FloatingTerminalPanel + })) +) +// Why: lazy so the WebP asset + overlay module aren't fetched unless the experimental flag is on. +const PetOverlay = lazy(() => import('../components/pet/PetOverlay')) +// Why: lazy so onboarding's step modules + assets aren't fetched for users past first-launch. +const OnboardingFlow = lazy(() => import('../components/onboarding/OnboardingFlow')) + +type BoundaryProps = { + boundaryId: string + resetKey?: string | number | boolean | null + title?: string + description?: string + children: React.ReactNode +} + +function ModalBoundary({ children, ...props }: BoundaryProps): React.JSX.Element { + return ( + + {children} + + ) +} + +function OverlayBoundary({ children, ...props }: BoundaryProps): React.JSX.Element { + return ( + + {children} + + ) +} + +function shouldMountUpdateCardForStatus(status: UpdateStatus): boolean { + if (status.state === 'idle') { + return false + } + if (status.state === 'checking' || status.state === 'not-available') { + return status.userInitiated === true + } + return true +} + +/** + * Every overlay and modal hosted at the App root, in a fixed sibling order so stacking stays + * stable. Each is gated so its chunk is only fetched once the surface can actually appear. + */ +export function AppRootSurfaces(props: { + floatingWorkspace: FloatingWorkspacePanelState + onboardingGate: OnboardingGate +}): React.JSX.Element { + const { floatingWorkspace, onboardingGate } = props + const { mountedLazyModalIds, shouldMountAddRepoDialog } = useLazyModalMounts() + const activeView = useAppStore((s) => s.activeView) + const activeModal = useAppStore((s) => s.activeModal) + const settings = useAppStore((s) => s.settings) + const statusBarVisible = useAppStore((s) => s.statusBarVisible) + const persistedUIReady = useAppStore((s) => s.persistedUIReady) + const petVisible = useAppStore((s) => s.petVisible) + const petEnabled = useAppStore((s) => s.settings?.experimentalPet === true) + const dictationState = useAppStore((s) => s.dictationState) + const updateStatus = useAppStore((s) => s.updateStatus) + const activeContextualTourId = useAppStore((s) => s.activeContextualTourId) + const hasSshCredentialRequest = useAppStore((s) => s.sshCredentialQueue.length > 0) + + const shouldMountSetupGuideTelemetryObserver = persistedUIReady + const shouldMountUpdateCard = shouldMountUpdateCardForStatus(updateStatus) + const shouldMountDictationController = + settings?.voice?.enabled === true || dictationState !== 'idle' + const renderPetOverlay = shouldRenderPetOverlay({ persistedUIReady, petEnabled, petVisible }) + + return ( + <> + {floatingWorkspace.shouldMountPanel ? ( + + + + + + ) : null} + {statusBarVisible ? ( + + } + > + + + + + ) : null} + {/* Why: keep in the entry bundle so a stale/corrupt lazy chunk can't strand users at Create. */} + {activeModal === 'new-workspace-composer' ? ( + + + + ) : null} + + {shouldMountAddRepoDialog ? ( + + + + ) : null} + {/* Why: Settings can start Add Project without Sidebar, so its handoff dialogs must share the root host. */} + {activeModal === 'confirm-non-git-folder' ? ( + + + + ) : null} + {activeModal === 'confirm-add-project-from-folder' ? ( + + + + ) : null} + {activeModal === 'project-added' ? ( + + + + ) : null} + + {/* Why: root overlays can render Radix s; keep inside the shared provider so lazy surfaces mount from any entry point. */} + + {mountedLazyModalIds.has('workspace-cleanup') ? ( + + + + ) : null} + + + {mountedLazyModalIds.has('quick-open') ? ( + + + + ) : null} + {mountedLazyModalIds.has('worktree-palette') ? ( + + + + ) : null} + {mountedLazyModalIds.has('setup-guide') ? ( + + + + ) : null} + {mountedLazyModalIds.has('feature-wall') ? ( + + + + ) : null} + {mountedLazyModalIds.has('feature-tips') ? ( + + + + ) : null} + + {shouldMountSetupGuideTelemetryObserver ? ( + + + + ) : null} + {activeContextualTourId !== null ? ( + + + + ) : null} + {/* Why: mount only after UI hydration, else a hidden pet flashes while the store still holds default visibility. */} + {renderPetOverlay ? ( + + + + + + ) : null} + {shouldMountUpdateCard ? ( + + + + + + ) : null} + + + + + + + + {/* Why: mount at App root to render once per session; internal cohort gate limits it to pre-telemetry users — see telemetry-plan.md §First-launch experience. */} + + + + + + + + {activeModal === 'delete-worktree' ? ( + + + + ) : null} + {activeModal === 'preserved-branch-review' ? ( + + + + ) : null} + + {hasSshCredentialRequest ? ( + + + + + + ) : null} + + + + + + + {onboardingGate.onboarding && onboardingGate.shouldRender ? ( + + + + + + ) : null} + {shouldMountDictationController ? ( + + + + + + ) : null} + + + + {/* Why: hosts a live terminal pane needing the link-routing preference context; mounting outside crashes it. */} + + + + + + + + + + ) +} diff --git a/src/renderer/src/app-shell/AppWorkspaceShell.tsx b/src/renderer/src/app-shell/AppWorkspaceShell.tsx new file mode 100644 index 00000000000..377386d6bbb --- /dev/null +++ b/src/renderer/src/app-shell/AppWorkspaceShell.tsx @@ -0,0 +1,250 @@ +import { Suspense, useRef } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' +import { translate } from '@/i18n/i18n' +import Sidebar from '../components/Sidebar' +import RightSidebar from '../components/right-sidebar' +import { RecoverableRenderErrorBoundary } from '../components/error-boundaries/RecoverableRenderErrorBoundary' +import { FloatingTerminalToggleButton } from '../components/floating-terminal/FloatingTerminalToggleButton' +import type { VirtualizedScrollAnchor } from '../hooks/useVirtualizedScrollAnchor' +import { TitlebarLeftControls } from './TitlebarLeftControls' +import { RightSidebarToggle, TitlebarMainStrip } from './TitlebarMainStrip' +import type { AppChromeLayout } from './use-app-chrome-layout' +import type { FloatingWorkspacePanelState } from './use-floating-workspace-panel' + +const Landing = lazy(() => import('../components/Landing')) +const WorktreeCreationPanel = lazy( + () => import('../components/worktree-creation/WorktreeCreationPanel') +) +const TaskPage = lazy(() => import('../components/TaskPage')) +const AutomationsPage = lazy(() => import('../components/automations/AutomationsPage')) +const ActivityPrototypePage = lazy(() => import('../components/activity/ActivityPrototypePage')) +const Settings = lazy(() => import('../components/settings/Settings')) +const ArtifactsPage = lazy(() => import('../components/artifacts/ArtifactsPage')) +const WorkspaceSpacePage = lazy(() => import('../components/workspace-space/WorkspaceSpacePage')) +const MobilePage = lazy(() => import('../components/mobile/MobilePage')) +const Terminal = lazy(() => import('../components/Terminal')) + +type WorktreeSidebarScrollRefs = { + scrollOffsetRef: React.MutableRefObject + scrollAnchorRef: React.MutableRefObject +} + +function WorktreeSidebar({ + layout, + scrollRefs +}: { + layout: AppChromeLayout + scrollRefs: WorktreeSidebarScrollRefs +}): React.JSX.Element { + return ( + + + + ) +} + +function ActivePage({ layout }: { layout: AppChromeLayout }): React.JSX.Element { + const { activeView, activeWorktreeId, activePendingCreationId, creationLayoutActive } = layout + return ( + <> + {activeView === 'settings' ? : null} + {activeView === 'artifacts' ? : null} + {activeView === 'tasks' ? : null} + {activeView === 'automations' ? : null} + {activeView === 'activity' ? : null} + {activeView === 'space' ? : null} + {activeView === 'mobile' ? : null} + {activeView === 'terminal' && creationLayoutActive && activePendingCreationId ? ( + + ) : null} + {activeView === 'terminal' && !activeWorktreeId && !creationLayoutActive ? : null} + + ) +} + +/** The left sidebar + titlebar + page/workbench content area + right sidebar. */ +export function AppWorkspaceShell(props: { + layout: AppChromeLayout + floatingWorkspace: FloatingWorkspacePanelState +}): React.JSX.Element { + const { layout, floatingWorkspace } = props + const titlebarLeftControls = + const titlebarMainStrip = + // Why: keep virtualized scroll memory above the sidebar's workspace/landing remount so the left list doesn't restart at scrollTop 0. + const scrollOffsetRef = useRef(0) + const scrollAnchorRef = useRef(null) + const sidebarScrollRefs = { scrollOffsetRef, scrollAnchorRef } + + return ( + // Why: workspace activation is a hot path; activeWorktreeId in reset keys would remount whole surfaces during wake. + +
+ {/* Why: keep the non-workspace titlebar inside this left+center wrapper so it doesn't span over the right-sidebar column. */} +
+ {/* Why: workspace view drops the full-width titlebar so tab groups extend to the top; settings/landing/tasks keep it. */} + {!layout.leftTitlebarChromeLayout.shouldMount ? ( +
+
{titlebarLeftControls}
+ {titlebarMainStrip} +
+ ) : null} +
+ {layout.showSidebar ? ( + layout.leftTitlebarChromeLayout.shouldMount ? ( + /* Why: when the sidebar is collapsed, take this titlebar-height header out of flex layout so the terminal/editor reclaim the left edge. */ +
+
+ {titlebarLeftControls} +
+ {/* Why: flex-1/min-h-0 slot needed under the fixed 36px header, else the sidebar collapses to content height and loses its scroll viewport. */} +
+ +
+
+ ) : ( + + ) + ) : null} +
+ {/* Why: automations owns its page header; the stacked titlebar would be an empty 36px stripe. */} + {layout.stackedSidebarOpen && layout.activeView !== 'automations' ? ( +
{titlebarMainStrip}
+ ) : null} +
+ {/* Why: match the RightSidebar header's 36px/top-0 so the toggle's vertical center is identical open vs closed — else the icon jitters. */} + {layout.workspaceChromeActive && !layout.rightSidebarOpen && ( +
+ {layout.showRightSidebarControls ? : null} +
+ )} +
+ {layout.shouldMountTerminalWorkbench ? ( +
+ + + + + +
+ ) : null} + + + + + +
+ {floatingWorkspace.showToggleButton ? ( + floatingWorkspace.setOpenWithFocus((open) => !open)} + /> + ) : null} +
+
+
+
+ {/* Why: keep the shell mounted for layout stability (heavy panels disconnect while closed); unmount on the distraction-free tasks view. */} + {layout.showRightSidebarControls ? ( + + + + ) : null} +
+
+ ) +} diff --git a/src/renderer/src/app-shell/TitlebarLeftControls.tsx b/src/renderer/src/app-shell/TitlebarLeftControls.tsx new file mode 100644 index 00000000000..352e3d121fb --- /dev/null +++ b/src/renderer/src/app-shell/TitlebarLeftControls.tsx @@ -0,0 +1,153 @@ +import { ArrowLeft, ArrowRight, MoreHorizontal, PanelLeft } from 'lucide-react' +import logo from '../../../../resources/logo.svg' +import { translate } from '@/i18n/i18n' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import { shouldShowWorktreeHistoryControls } from '../lib/titlebar-worktree-history-controls' +import { + canGoBackWorktreeHistory, + canGoForwardWorktreeHistory +} from '@/store/slices/worktree-nav-history' +import { useShortcutLabel } from '../hooks/useShortcutLabel' +import { useAppStore } from '../store' +import { hasCustomTitleBar, isMac } from './app-window-chrome' +import type { AppChromeLayout } from './use-app-chrome-layout' + +/** + * The titlebar's left cluster: window chrome padding, app name, sidebar toggle, and the + * worktree back/forward pair. Shared by the full-width titlebar and the sidebar-width left + * header so the agent badge popover isn't duplicated. + */ +export function TitlebarLeftControls({ layout }: { layout: AppChromeLayout }): React.JSX.Element { + const toggleSidebar = useAppStore((s) => s.toggleSidebar) + const updateSettings = useAppStore((s) => s.updateSettings) + const canGoBackWorktree = useAppStore(canGoBackWorktreeHistory) + const canGoForwardWorktree = useAppStore(canGoForwardWorktreeHistory) + const leftSidebarShortcutLabel = useShortcutLabel('sidebar.left.toggle') + const historyBackShortcutLabel = useShortcutLabel('worktree.history.back') + const historyForwardShortcutLabel = useShortcutLabel('worktree.history.forward') + + return ( + // Why: measure the ENTIRE row so TabGroupPanel's collapse spacer reserves enough width; measuring only the inner cluster left back/forward over the first tab. + // Why: collapsed mode floats in a w-0 wrapper; w-max stops Windows Chromium from shrinking the app name to one glyph. +
+
+ {isMac && !layout.isFullScreen ? ( +
+ ) : hasCustomTitleBar ? ( + /* Why: Windows/Linux remove the native title bar, so render the logo plus a ··· button that pops the application menu (as Alt does). */ + <> + + + + + + + {translate('auto.App.8b0b8eb54f', 'Application menu')} + + + + ) : ( +
+ )} + {layout.showSidebar && !hasCustomTitleBar && layout.showTitlebarAppName && ( + + +
+ + {translate('auto.App.5096cbbc86', 'Orca')} + +
+
+ + { + void updateSettings({ showTitlebarAppName: false }) + }} + > + {translate('auto.App.e81217c1b7', 'Hide App Name')} + + +
+ )} + {layout.showSidebar && ( + + + + + + {translate('auto.App.ce37cf5279', 'Toggle sidebar ({{value0}})', { + value0: leftSidebarShortcutLabel + })} + + + )} +
+ {/* Why: Back/Forward span worktree + page history, so show the cluster wherever the shortcut is live (hidden in Settings/non-stack views). */} + {shouldShowWorktreeHistoryControls(layout.activeView) && ( + // With the sidebar collapsed the header shrink-wraps and ml-auto has no spare width, so keep a fixed gutter before Back. +
+ + + + + + {translate('auto.App.fe21e8f6f5', 'Go back ({{value0}})', { + value0: historyBackShortcutLabel + })} + + + + + + + + {translate('auto.App.f7aa73e785', 'Go forward ({{value0}})', { + value0: historyForwardShortcutLabel + })} + + +
+ )} +
+ ) +} diff --git a/src/renderer/src/app-shell/TitlebarMainStrip.tsx b/src/renderer/src/app-shell/TitlebarMainStrip.tsx new file mode 100644 index 00000000000..817c7634e6a --- /dev/null +++ b/src/renderer/src/app-shell/TitlebarMainStrip.tsx @@ -0,0 +1,80 @@ +import { Minimize2, PanelRight } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal' +import { ActivityTitlebarControls } from '../components/activity/ActivityTitlebarControls' +import { useShortcutLabel } from '../hooks/useShortcutLabel' +import { useAppStore } from '../store' +import { hasCustomTitleBar } from './app-window-chrome' +import type { AppChromeLayout } from './use-app-chrome-layout' + +export function RightSidebarToggle(): React.JSX.Element { + const toggleRightSidebar = useAppStore((s) => s.toggleRightSidebar) + const rightSidebarShortcutLabel = useShortcutLabel('sidebar.right.toggle') + return ( + + + + + + {translate('auto.App.c184e056de', 'Toggle right sidebar ({{value0}})', { + value0: rightSidebarShortcutLabel + })} + + + ) +} + +/** The titlebar's center/right strip: the tab-strip portal slot and the trailing chrome buttons. */ +export function TitlebarMainStrip({ layout }: { layout: AppChromeLayout }): React.JSX.Element { + const handleToggleExpand = (): void => { + if (!layout.effectiveActiveTabId) { + return + } + window.dispatchEvent( + new CustomEvent(TOGGLE_TERMINAL_PANE_EXPAND_EVENT, { + detail: { tabId: layout.effectiveActiveTabId } + }) + ) + } + + return ( + <> + {layout.activeView === 'activity' ? ( + + ) : layout.creationLayoutActive ? null : ( +
+ )} + {layout.showTitlebarExpandButton && ( + + + + + + {translate('auto.App.c1cf0b0e4a', 'Collapse pane')} + + + )} + {/* Why: the open right sidebar's header renders its own close button, so hide this duplicate. */} + {layout.showRightSidebarControls && !layout.rightSidebarOpen ? : null} + {/* Why: reserve space so the Windows/Linux window-controls overlay doesn't obscure content. */} + {hasCustomTitleBar &&
} + + ) +} diff --git a/src/renderer/src/app-shell/WindowControls.tsx b/src/renderer/src/app-shell/WindowControls.tsx new file mode 100644 index 00000000000..0ab8ad8d0fa --- /dev/null +++ b/src/renderer/src/app-shell/WindowControls.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react' +import { translate } from '@/i18n/i18n' + +// Why: Windows and Linux both remove the native title bar, so we render our own min/max/close buttons (Fluent/Win11-style SVGs). +export function WindowControls(): React.JSX.Element { + const [maximized, setMaximized] = useState(false) + useEffect(() => { + // Why: maximize-changed only fires on transitions; seed from main on mount so a startup-maximized window shows the right icon. + let cancelled = false + void window.api.ui.isMaximized().then((value) => { + if (!cancelled) { + setMaximized(value) + } + }) + const unsubscribe = window.api.ui.onMaximizeChanged(setMaximized) + return () => { + cancelled = true + unsubscribe() + } + }, []) + return ( +
+ + + +
+ ) +} diff --git a/src/renderer/src/app-shell/app-command-handlers.ts b/src/renderer/src/app-shell/app-command-handlers.ts new file mode 100644 index 00000000000..11c69425552 --- /dev/null +++ b/src/renderer/src/app-shell/app-command-handlers.ts @@ -0,0 +1,248 @@ +import { useShallow } from 'zustand/react/shallow' +import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility' +import { isFloatingWorkspacePanelFocused } from '@/lib/floating-workspace-terminal-actions' +import { requestScrollToCurrentWorkspaceRevealAndRename } from '@/lib/scroll-to-current-workspace-status' +import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' +import { shouldShowWorktreeHistoryControls } from '../lib/titlebar-worktree-history-controls' +import { TOGGLE_WORKSPACE_BOARD_EVENT } from '../components/sidebar/useWorkspaceBoardPanel' +import { useAppStore } from '../store' +import type { usePluginCommands } from '@/store/plugin-panels' +import { isGitRepoKind } from '../../../shared/repo-kind' +import type { + KeybindingActionId, + KeybindingContext, + PhysicalModifierToken +} from '../../../shared/keybindings' +import { shortcutPlatform } from './app-window-chrome' + +type AppStoreState = ReturnType + +// Abstraction over a real KeyboardEvent and a synthetic double-tap gesture so one dispatch path serves both; KeybindingInput-compatible. +export type ShortcutDispatchInput = { + key?: string + code?: string + altKey?: boolean + metaKey?: boolean + ctrlKey?: boolean + shiftKey?: boolean + doubleTapModifier?: PhysicalModifierToken + target: EventTarget | null + defaultPrevented: boolean + preventDefault: () => void +} + +export type AppShortcutActions = ReturnType + +export type AppShortcutState = { + activeView: AppStoreState['activeView'] + activeWorktreeId: AppStoreState['activeWorktreeId'] + actions: AppShortcutActions + creationLayoutActive: boolean + floatingTerminalEnabled: boolean + floatingTerminalOpen: boolean + floatingVisibleTabCount: number + keybindings: AppStoreState['keybindings'] + openFloatingWorkspaceMaximized: () => void + pluginCommands: ReturnType + setFloatingTerminalOpen: (open: boolean) => void + terminalShortcutPolicy: NonNullable['terminalShortcutPolicy'] + workspaceChromeActive: boolean +} + +export function useAppShortcutActions() { + // Why: consolidate action refs into one useShallow subscription so React runs one equality check per store mutation instead of one per action. + return useAppStore( + useShallow((s) => ({ + toggleSidebar: s.toggleSidebar, + toggleRightSidebar: s.toggleRightSidebar, + setRightSidebarOpen: s.setRightSidebarOpen, + setRightSidebarTab: s.setRightSidebarTab, + showRightSidebarFiles: s.showRightSidebarFiles, + showRightSidebarSearch: s.showRightSidebarSearch, + openDiffNotesSendMenuForActiveWorktree: s.openDiffNotesSendMenuForActiveWorktree + })) + ) +} + +export function getKeybindingContext(target: EventTarget | null): KeybindingContext { + return target instanceof HTMLElement && target.classList.contains('xterm-helper-textarea') + ? 'terminal' + : 'app' +} + +/** + * Builds the app-level handlers for every keybindable action. Each returns whether it claimed + * the chord, so an unavailable surface (settings view, closed floating panel) falls through to + * the terminal or the next handler instead of silently no-opping. + * + * `input` is absent when a command is invoked from the palette/menu rather than a key event. + */ +export function createAppCommandHandlers( + state: AppShortcutState, + input?: ShortcutDispatchInput, + keybindingContext: KeybindingContext = 'app' +): Map boolean> { + const { + activeView, + activeWorktreeId, + actions, + creationLayoutActive, + floatingTerminalEnabled, + floatingTerminalOpen, + keybindings, + openFloatingWorkspaceMaximized, + terminalShortcutPolicy, + workspaceChromeActive + } = state + const floatingWorkspaceFocused = isFloatingWorkspacePanelFocused() + const canRevealRightSidebar = !creationLayoutActive && canShowRightSidebarForView(activeView) + const claim = (actionId: KeybindingActionId, run: () => void): boolean => { + input?.preventDefault() + if ( + input && + keybindingContext === 'terminal' && + (terminalShortcutPolicy ?? 'orca-first') === 'orca-first' + ) { + showTerminalShortcutCaptureNotification({ + actionId, + platform: shortcutPlatform, + keybindings + }) + } + run() + return true + } + const revealRightSidebarTab = ( + actionId: KeybindingActionId, + tab: Parameters[0] + ): boolean => + canRevealRightSidebar + ? claim(actionId, () => { + actions.setRightSidebarTab(tab) + actions.setRightSidebarOpen(true) + }) + : false + + return new Map boolean>([ + [ + 'worktree.history.back', + () => { + if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) { + return false + } + return claim('worktree.history.back', () => useAppStore.getState().goBackWorktree()) + } + ], + [ + 'worktree.history.forward', + () => { + if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) { + return false + } + return claim('worktree.history.forward', () => useAppStore.getState().goForwardWorktree()) + } + ], + ['sidebar.left.toggle', () => claim('sidebar.left.toggle', () => actions.toggleSidebar())], + [ + 'sidebar.sleepingWorkspaces.toggle', + () => + claim('sidebar.sleepingWorkspaces.toggle', () => { + const store = useAppStore.getState() + const nextShowSleeping = !store.showSleepingWorkspaces + store.setShowSleepingWorkspaces(nextShowSleeping) + if (nextShowSleeping) { + store.setSidebarOpen(true) + } + }) + ], + [ + 'floatingWorkspace.maximize', + () => { + if (floatingTerminalOpen || !floatingTerminalEnabled) { + return false + } + return claim('floatingWorkspace.maximize', openFloatingWorkspaceMaximized) + } + ], + [ + 'tab.rename', + () => { + const store = useAppStore.getState() + if ( + !workspaceChromeActive || + floatingWorkspaceFocused || + store.activeTabType !== 'terminal' || + !store.activeTabId + ) { + return false + } + return claim('tab.rename', () => store.setRenamingTabId(store.activeTabId!)) + } + ], + [ + 'workspace.rename', + () => { + if (!workspaceChromeActive || floatingWorkspaceFocused || !activeWorktreeId) { + return false + } + return claim('workspace.rename', () => { + useAppStore.getState().setSidebarOpen(true) + requestScrollToCurrentWorkspaceRevealAndRename() + }) + } + ], + [ + 'workspace.openBoard', + () => { + if (activeView === 'settings') { + return false + } + return claim('workspace.openBoard', () => { + useAppStore.getState().setSidebarOpen(true) + window.dispatchEvent(new CustomEvent(TOGGLE_WORKSPACE_BOARD_EVENT)) + }) + } + ], + [ + 'view.tasks', + () => { + const store = useAppStore.getState() + if (activeView === 'settings' || !store.repos.some((repo) => isGitRepoKind(repo))) { + return false + } + return claim('view.tasks', () => store.openTaskPage()) + } + ], + [ + 'sidebar.right.toggle', + () => + canRevealRightSidebar + ? claim('sidebar.right.toggle', () => actions.toggleRightSidebar()) + : false + ], + [ + 'sidebar.explorer.toggle', + () => + canRevealRightSidebar + ? claim('sidebar.explorer.toggle', () => actions.showRightSidebarFiles()) + : false + ], + [ + 'sidebar.search.toggle', + () => + canRevealRightSidebar + ? claim('sidebar.search.toggle', () => actions.showRightSidebarSearch()) + : false + ], + [ + 'sidebar.sourceControl.toggle', + () => + // Why: the terminal's own find bar owns this chord while it is open. + document.querySelector('[data-terminal-search-root]') + ? false + : revealRightSidebarTab('sidebar.sourceControl.toggle', 'source-control') + ], + ['sidebar.checks.toggle', () => revealRightSidebarTab('sidebar.checks.toggle', 'checks')], + ['sidebar.ports.toggle', () => revealRightSidebarTab('sidebar.ports.toggle', 'ports')] + ]) +} diff --git a/src/renderer/src/app-shell/app-window-chrome.ts b/src/renderer/src/app-shell/app-window-chrome.ts new file mode 100644 index 00000000000..03b91d50a27 --- /dev/null +++ b/src/renderer/src/app-shell/app-window-chrome.ts @@ -0,0 +1,13 @@ +import { + isPairedWebClientWindow, + shouldRenderDesktopWindowChrome +} from '@/lib/desktop-window-chrome' + +export const isMac = navigator.userAgent.includes('Mac') +const isWindows = !isMac && navigator.userAgent.includes('Windows') +export const shortcutPlatform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' +// Why: Windows and Linux remove the native title bar so the renderer draws its own chrome; paired web clients run in a browser tab and must not. +export const hasCustomTitleBar = shouldRenderDesktopWindowChrome({ + platform: shortcutPlatform, + isWebClient: isPairedWebClientWindow() +}) diff --git a/src/renderer/src/app-shell/use-app-chrome-layout.ts b/src/renderer/src/app-shell/use-app-chrome-layout.ts new file mode 100644 index 00000000000..f675ae7b79e --- /dev/null +++ b/src/renderer/src/app-shell/use-app-chrome-layout.ts @@ -0,0 +1,147 @@ +import { useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { SYNC_FIT_PANES_EVENT } from '@/constants/terminal' +import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility' +import { resolveLeftSidebarStyleVariables } from '@/lib/left-sidebar-appearance' +import { resolveLeftTitlebarChromeLayout } from '@/lib/titlebar-left-chrome' +import { shouldShowWorktreeCreationSurface } from '@/lib/worktree-creation-surface' +import { useAppStore } from '../store' +import { selectActiveTerminalChromeState } from '../store/active-terminal-chrome-selector' +import { useSystemPrefersDark } from '../components/terminal-pane/use-system-prefers-dark' +import { + hasRequestedBackgroundTerminalWorktreeMount, + subscribeBackgroundTerminalWorktreeMountRequests +} from '../components/terminal/background-terminal-worktree-mount' + +export type AppChromeLayout = ReturnType + +/** + * Derives the App shell's layout decisions — which titlebar variant mounts, whether the + * workspace owns the tab strip, and which surfaces stay mounted — from store state. + */ +export function useAppChromeLayout() { + const activeView = useAppStore((s) => s.activeView) + const sidebarOpen = useAppStore((s) => s.sidebarOpen) + const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen) + const rightSidebarTab = useAppStore((s) => s.rightSidebarTab) + const rightSidebarExplorerView = useAppStore((s) => s.rightSidebarExplorerView) + const isFullScreen = useAppStore((s) => s.isFullScreen) + const settings = useAppStore((s) => s.settings) + const activePendingCreationId = useAppStore((s) => s.activePendingCreationId) + // Why: the creation surface owns the tab strip from the first pending frame; gating on the delayed loader flag swapped the tab bar mid-create. + const activePendingCreationExists = useAppStore( + (s) => + s.activePendingCreationId !== null && + s.pendingWorktreeCreations[s.activePendingCreationId] !== undefined + ) + const { + activeWorktreeId, + tabCount, + effectiveActiveTabId, + activeTabCanExpand, + effectiveActiveTabExpanded + } = useAppStore(useShallow(selectActiveTerminalChromeState)) + const backgroundTerminalMountRequested = useSyncExternalStore( + subscribeBackgroundTerminalWorktreeMountRequests, + hasRequestedBackgroundTerminalWorktreeMount, + hasRequestedBackgroundTerminalWorktreeMount + ) + + const systemPrefersDark = useSystemPrefersDark() + const leftSidebarStyle = useMemo( + () => resolveLeftSidebarStyleVariables(settings, systemPrefersDark), + [settings, systemPrefersDark] + ) as React.CSSProperties | undefined + + const canMountTerminalWorkbenchNow = activeWorktreeId !== null || backgroundTerminalMountRequested + // Why a latch in state, not a ref: the write has to be visible to the next render, and a + // render-phase ref write would also survive a render React discards. Setting state during + // render is the supported way to derive it, and the `||` below keeps this render correct. + const [hasMountedTerminalWorkbench, setHasMountedTerminalWorkbench] = useState(false) + if (canMountTerminalWorkbenchNow && !hasMountedTerminalWorkbench) { + setHasMountedTerminalWorkbench(true) + } + // Why: skip the terminal bundle on the landing path, but once mounted keep hidden panes alive through sleep/shutdown when activeWorktreeId briefly goes null. + const shouldMountTerminalWorkbench = canMountTerminalWorkbenchNow || hasMountedTerminalWorkbench + // Why: visible worktree creation owns its faux tab strip start to finish; keep the previous workspace mounted for retention without real chrome. + const creationLayoutActive = shouldShowWorktreeCreationSurface({ + activeView, + activePendingCreationId, + hasActivePendingCreation: activePendingCreationExists + }) + const workspaceChromeActive = + activeView === 'terminal' && activeWorktreeId !== null && !creationLayoutActive + const hasTabBar = tabCount >= 2 + // Activity/Space are full-page navigation surfaces (like Settings), so the worktree sidebar is hidden there. + const showSidebar = + activeView !== 'settings' && activeView !== 'activity' && activeView !== 'space' + // Tasks/Landing show the full titlebar only when the sidebar is collapsed; open, they mirror workspace view (creation suppresses it). + const stackedSidebarOpen = + !workspaceChromeActive && !creationLayoutActive && showSidebar && sidebarOpen + // Visible creation keeps only the top-left window chrome; tabs and right-sidebar chrome stay gated by workspaceChromeActive. + const leftTitlebarChromeLayout = resolveLeftTitlebarChromeLayout({ + workspaceChromeActive, + stackedSidebarOpen, + creationLayoutActive, + sidebarOpen + }) + + // Why: useLayoutEffect fires before paint, so dispatching SYNC_FIT_PANES_EVENT reflows the terminal in the same frame as the width change — no wrongly-sized transient. + useLayoutEffect(() => { + window.dispatchEvent(new CustomEvent(SYNC_FIT_PANES_EVENT)) + }, [sidebarOpen, rightSidebarOpen]) + + const titlebarLeftControlsRef = useRef(null) + const [collapsedSidebarHeaderWidth, setCollapsedSidebarHeaderWidth] = useState(0) + useLayoutEffect(() => { + const controls = titlebarLeftControlsRef.current + if (!controls) { + return + } + + const updateWidth = (): void => { + setCollapsedSidebarHeaderWidth(controls.getBoundingClientRect().width) + } + + updateWidth() + const observer = new ResizeObserver(() => { + updateWidth() + }) + observer.observe(controls) + return () => observer.disconnect() + }, [ + isFullScreen, + settings?.showTitlebarAppName, + showSidebar, + leftTitlebarChromeLayout.isFloating, + sidebarOpen + ]) + + return { + activeView, + activeWorktreeId, + activePendingCreationId, + activeTabCanExpand, + effectiveActiveTabId, + collapsedSidebarHeaderWidth, + creationLayoutActive, + isFullScreen, + leftSidebarStyle, + leftTitlebarChromeLayout, + rightSidebarExplorerView, + rightSidebarOpen, + rightSidebarTab, + shouldMountTerminalWorkbench, + showSidebar, + // Full-page navigation surfaces own the whole content area, so suppress right-sidebar controls. + showRightSidebarControls: !creationLayoutActive && canShowRightSidebarForView(activeView), + showTitlebarAppName: settings?.showTitlebarAppName !== false, + showTitlebarExpandButton: workspaceChromeActive && !hasTabBar && effectiveActiveTabExpanded, + sidebarOpen, + stackedSidebarOpen, + // Why: the workbench stays mounted while hidden, so visibility tracks the same condition separately. + terminalWorkbenchVisible: workspaceChromeActive, + titlebarLeftControlsRef, + workspaceChromeActive + } +} diff --git a/src/renderer/src/app-shell/use-app-session-persistence.ts b/src/renderer/src/app-shell/use-app-session-persistence.ts new file mode 100644 index 00000000000..aed0685dbb7 --- /dev/null +++ b/src/renderer/src/app-shell/use-app-session-persistence.ts @@ -0,0 +1,198 @@ +import { useEffect } from 'react' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '../store' +import { isRemoteWorkspaceSnapshotApplyInProgress } from '../hooks/useIpcEvents' +import { createSessionWriteSubscriber } from '../lib/session-write-subscriber' +import { buildActiveViewUnloadPatch } from '../lib/active-view-persist' +import { + isIntentionalAppRestartInProgress, + registerUpdaterBeforeUnloadBypass +} from '../lib/updater-beforeunload' +import { + buildWorkspaceSessionPayload, + shouldPersistWorkspaceSession +} from '../lib/workspace-session' +import { + buildWorkspaceSessionHostSnapshots, + patchWorkspaceSessionByHost +} from '../lib/workspace-session-host-persistence' +import { + createShutdownCheckpointBeforeUnloadHandler, + createShutdownCheckpointGuard +} from '../lib/shutdown-checkpoint-guard' +import { shutdownBufferCaptures } from '../components/terminal-pane/shutdown-buffer-captures' +import { dispatchWindowCloseRequest } from '../components/window-close-request-coordinator' +import { + ORCA_APP_RESTART_ABORTED_EVENT, + ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT +} from '../../../shared/updater-renderer-events' +import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events' +import type { RemoteWorkspacePatchResult } from '../../../shared/remote-workspace-types' + +// Why: bound the resume-record loss window on a hard kill to ~1 min; capture skips unchanged records so per-tick cost is negligible. +const SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS = 60_000 + +function applyRemoteWorkspacePatchStatus( + targetId: string, + result: RemoteWorkspacePatchResult +): void { + const store = useAppStore.getState() + if (result.ok) { + store.setRemoteWorkspaceSyncStatus(targetId, { + phase: 'synced', + direction: 'push', + revision: result.snapshot.revision, + updatedAt: result.snapshot.updatedAt, + lastSyncedAt: Date.now(), + message: translate('auto.App.332dbfa497', 'Workspace uploaded') + }) + return + } + store.setRemoteWorkspaceSyncStatus(targetId, { + phase: result.reason === 'stale-revision' ? 'conflict' : 'offline', + direction: 'push', + revision: result.snapshot?.revision, + updatedAt: result.snapshot?.updatedAt, + lastSyncedAt: Date.now(), + message: + result.message ?? + (result.reason === 'stale-revision' + ? translate( + 'auto.hooks.useIpcEvents.workspaceChangedOnAnotherDevice', + 'Workspace changed on another device' + ) + : translate('auto.hooks.useIpcEvents.2fe88c2e06', 'Remote workspace sync unavailable')) + }) +} + +/** + * Writes durable renderer session state to disk: the debounced per-host writer, the remote + * workspace upload chain, and the synchronous shutdown checkpoint. + */ +export function useAppSessionPersistence(): void { + useEffect(() => registerUpdaterBeforeUnloadBypass(), []) + + // Why: session persistence only writes to disk; a Zustand subscribe() outside React drops ~15 render-cycle subscriptions and their re-renders on every tab/file/browser change. + useEffect(() => { + return createSessionWriteSubscriber({ + store: useAppStore, + shouldSchedulePersist: () => !isRemoteWorkspaceSnapshotApplyInProgress(), + persist: ({ patch }) => { + const state = useAppStore.getState() + // Why: route each host's worktree-scoped slice to its own partition; return the local write so the remote-workspace upload chain below keeps its ordering. + const localWrite = patchWorkspaceSessionByHost(window.api.session, patch, state) + void localWrite + const hydratedTargetIds = Array.from(state.remoteWorkspaceHydratedTargetIds).filter( + (targetId) => state.remoteWorkspaceSyncStatusByTargetId[targetId]?.phase !== 'conflict' + ) + if (hydratedTargetIds.length > 0) { + void localWrite + .then(() => window.api.remoteWorkspace?.setForConnectedTargets({ hydratedTargetIds })) + .then((results) => { + for (const { targetId, result } of results ?? []) { + applyRemoteWorkspacePatchStatus(targetId, result) + } + }) + .catch((err) => { + for (const targetId of hydratedTargetIds) { + useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, { + phase: 'error', + direction: 'push', + message: err instanceof Error ? err.message : 'Workspace upload failed' + }) + } + }) + } + } + }) + }, []) + + // On shutdown, capture terminal scrollback buffers and flush all durable + // renderer state through one synchronous main-process checkpoint. + useEffect(() => { + // Why: beforeunload fires twice during a manual quit — once from the + // synthetic dispatch in the onWindowCloseRequested handler (captures + // good data while TerminalPanes are still mounted), and again from the + // native window close triggered by confirmWindowClose(). Between these + // two firings, PTY exit events can arrive and unmount TerminalPanes, + // emptying shutdownBufferCaptures. The guard prevents the second call + // from overwriting the good session data with an empty snapshot. + const shutdownCheckpoint = createShutdownCheckpointGuard(() => { + const shouldCaptureSession = shouldPersistWorkspaceSession(useAppStore.getState()) + if (shouldCaptureSession) { + for (const capture of shutdownBufferCaptures.values()) { + try { + capture({ includeLocalBuffers: false }) + } catch { + // Don't let one pane's failure block the rest. + } + } + // Why: agent provider session ids live only in agentStatusByPaneKey, + // which is in-memory. Capture them into the persisted sleeping-session + // map so a daemon/session death while the app is closed can still + // cold-restore via the agent's resume command (#5232). + useAppStore.getState().captureAllSleepingAgentSessions('quit') + } + // Why: re-read state after capture() calls populated scrollback buffers + // into the store via Zustand setters. The earlier read is only for the + // gating flags and would miss those updates. + const freshState = useAppStore.getState() + let sessionSnapshots: ReturnType = [] + try { + sessionSnapshots = shouldCaptureSession + ? buildWorkspaceSessionHostSnapshots(buildWorkspaceSessionPayload(freshState), freshState) + : [] + } catch (error) { + // Why: dirty drafts exist only in the full session snapshot. + if ( + !isIntentionalAppRestartInProgress() || + freshState.openFiles.some((file) => file.isDirty) + ) { + throw error + } + console.error('[app] Full renderer session snapshot failed; using durable session', error) + window.api.app.stageBeforeUnloadSync({ + sessions: [], + ui: buildActiveViewUnloadPatch(freshState) + }) + return + } + window.api.app.stageBeforeUnloadSync({ + sessions: sessionSnapshots, + ui: buildActiveViewUnloadPatch(freshState) + }) + }) + const persistBeforeUnload = createShutdownCheckpointBeforeUnloadHandler(shutdownCheckpoint) + window.addEventListener('beforeunload', persistBeforeUnload) + window.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, shutdownCheckpoint.reset) + window.addEventListener(ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, shutdownCheckpoint.reset) + window.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, shutdownCheckpoint.reset) + return () => { + window.removeEventListener('beforeunload', persistBeforeUnload) + window.removeEventListener(ORCA_APP_RESTART_ABORTED_EVENT, shutdownCheckpoint.reset) + window.removeEventListener( + ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, + shutdownCheckpoint.reset + ) + window.removeEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, shutdownCheckpoint.reset) + } + }, []) + + // Why: beforeunload never fires on a hard kill (crash, forced update, TerminateProcess), so periodically capture agent session ids (not scrollback) so live agents keep a resume record. + useEffect(() => { + const timer = window.setInterval(() => { + if (!shouldPersistWorkspaceSession(useAppStore.getState())) { + return + } + useAppStore.getState().captureAllSleepingAgentSessions('periodic') + }, SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS) + return () => window.clearInterval(timer) + }, []) + + // Why: subscribe at the always-mounted App root — Terminal owns the confirm flow but isn't mounted on the landing page, so subscribing there left File→Exit / Ctrl+Q with no listener (#5144). + useEffect(() => { + return window.api.ui.onWindowCloseRequested(dispatchWindowCloseRequest) + }, []) + + // Why no periodic scrollback save: the old 3-min re-serialize (#461) stalled the main thread for seconds; the out-of-process daemon (#729) is the durable replacement, non-daemon users lose in-session scrollback on unexpected exit. +} diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts new file mode 100644 index 00000000000..1bffafdb4f2 --- /dev/null +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -0,0 +1,49 @@ +import { useAppStore } from '../store' +import { useIpcEvents } from '../hooks/useIpcEvents' +import { useAutomationDispatchEvents } from '../hooks/useAutomationDispatchEvents' +import { useAutoAckViewedAgent } from '../hooks/useAutoAckViewedAgent' +import { useEditorExternalWatch } from '../hooks/useEditorExternalWatch' +import { useGlobalFileDrop } from '../hooks/useGlobalFileDrop' +import { useAppMenuPaste } from '../hooks/useAppMenuPaste' +import { useAppMenuSelectionActions } from '../hooks/useAppMenuSelectionActions' +import { useLargeTextControlPaste } from '../hooks/useLargeTextControlPaste' +import { + resolvePrimarySelectionMiddleClickPaste, + usePrimarySelectionPaste +} from '../hooks/usePrimarySelectionPaste' +import { useRadixBodyPointerEventsRecovery } from '../hooks/useRadixBodyPointerEventsRecovery' +import { useGitStatusPolling } from '../components/right-sidebar/useGitStatusPolling' +import { useOsc52ClipboardDefaultOnNotice } from '../components/terminal-pane/osc52-clipboard-default-on-notice' +import { useWebSessionTabsSync } from '../runtime/web-session-tabs-sync' +import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime-recovery-triggers' + +/** + * App-level subscriptions that must outlive any individual surface. Each one is here because + * the component that consumes its result unmounts (right sidebar, explorer, terminal) or is + * absent entirely on the landing path. + */ +export function useAppShellServices(): void { + const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) + const persistedUIReady = useAppStore((s) => s.persistedUIReady) + const primarySelectionMiddleClickPaste = useAppStore((s) => + resolvePrimarySelectionMiddleClickPaste(s.settings?.primarySelectionMiddleClickPaste) + ) + + useRadixBodyPointerEventsRecovery() + useWebSessionTabsSync() + // Subscribe to IPC push events + useIpcEvents() + useRemoteRuntimeRecoveryTriggers() + useAutomationDispatchEvents() + // Why: git polling lives at App level (RightSidebar unmounts when closed, stranding stale Rebasing/Merging badges); gate on workspaceSessionReady so it doesn't compete with first paint. + useGitStatusPolling({ enabled: workspaceSessionReady }) + // Why: wire file-change watching at App level so the editor keeps hearing FS changes when Explorer unmounts (right-sidebar switches to Source Control/Checks). + useEditorExternalWatch() + useGlobalFileDrop() + useAutoAckViewedAgent() + useAppMenuPaste() + useAppMenuSelectionActions() + useLargeTextControlPaste() + usePrimarySelectionPaste(primarySelectionMiddleClickPaste) + useOsc52ClipboardDefaultOnNotice(persistedUIReady) +} diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts new file mode 100644 index 00000000000..caece413c1a --- /dev/null +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -0,0 +1,335 @@ +import { useEffect, useRef } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { syncZoomCSSVar } from '@/lib/ui-zoom' +import { installCodexDetachedPaneRestartExecutor } from '@/components/terminal-pane/codex-detached-pane-restart-scheduler' +import { useAppStore } from '../store' +import { WORKTREE_REFRESH_CONCURRENCY } from '../store/slices/worktrees' +import { sweepRestoredCodexPanesForStaleAccounts } from '../lib/codex-stale-pane-sweep' +import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-persistence' +import { + collectFolderWorkspaceKeysFromSession, + collectWorktreeHydrationRepoIdsFromSession +} from '../lib/workspace-session-hydration-keys' +import { hydratePersistedUIAfterStartupRead } from '../lib/startup-ui-hydration' +import { + logRendererStartupDiagnostic, + timeRendererStartupStep, + timeRendererStartupSyncStep +} from '../startup/startup-diagnostics' +import { recoverFromDegradedStartup } from '../startup/startup-degraded-recovery' +import { restoreSshConnectionsForStartup } from '../startup/startup-ssh-connection-restore' +import { publishTerminalViewAttributesAtAppStart } from '../components/terminal-pane/terminal-appearance' +import { getSystemPrefersDark } from '../lib/terminal-theme' +import { + collectTerminalProviderSnapshotPtyIds, + refreshTerminalProviderSnapshotCapabilities +} from '../components/terminal/terminal-provider-snapshot-capability' +import { + getRepoExecutionHostId, + isRuntimeOwnedSshTargetId, + parseExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { mapWithConcurrency } from '../../../shared/map-with-concurrency' +import type { OnboardingState } from '../../../shared/onboarding-state-types' + +async function listRuntimeSessionHostIdsForStartup(): Promise { + try { + return (await window.api.runtimeEnvironments.list()).map((environment) => + toRuntimeExecutionHostId(environment.id) + ) + } catch (err) { + console.warn('Failed to list runtime session hosts for startup:', err) + return [] + } +} + +function useStartupActions() { + // Why: consolidate action refs into one useShallow subscription so React runs one equality check per store mutation instead of one per action. + return useAppStore( + useShallow((s) => ({ + fetchReposForAllHosts: s.fetchReposForAllHosts, + awaitLocalRepoCatalogSettlement: s.awaitLocalRepoCatalogSettlement, + fetchProjectGroupsForAllHosts: s.fetchProjectGroupsForAllHosts, + fetchFolderWorkspacesForAllHosts: s.fetchFolderWorkspacesForAllHosts, + fetchAllWorktrees: s.fetchAllWorktrees, + fetchWorktrees: s.fetchWorktrees, + fetchWorktreeLineage: s.fetchWorktreeLineage, + fetchOrcaProfiles: s.fetchOrcaProfiles, + fetchSettings: s.fetchSettings, + fetchKeybindings: s.fetchKeybindings, + initGitHubCache: s.initGitHubCache, + hydrateWorkspaceSession: s.hydrateWorkspaceSession, + hydrateTabsSession: s.hydrateTabsSession, + hydrateEditorSession: s.hydrateEditorSession, + hydrateBrowserSession: s.hydrateBrowserSession, + fetchBrowserSessionProfiles: s.fetchBrowserSessionProfiles, + reconnectPersistedTerminals: s.reconnectPersistedTerminals, + setDeferredSshReconnectTargets: s.setDeferredSshReconnectTargets, + setSshConnectionState: s.setSshConnectionState, + hydratePersistedUI: s.hydratePersistedUI, + setHydrationSucceeded: s.setHydrationSucceeded, + pruneLastVisitedTimestamps: s.pruneLastVisitedTimestamps, + seedActiveWorktreeLastVisitedIfMissing: s.seedActiveWorktreeLastVisitedIfMissing + })) + ) +} + +/** + * Runs the renderer's one-shot boot chain: settings, persisted UI, the local repo catalog, + * the workspace session, SSH reconnect, and terminal restoration — then unlocks the session + * writer. A failure anywhere leaves disk state untouched and boots in degraded no-save mode. + */ +export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingState) => void): void { + const actions = useStartupActions() + // Why a ref: the boot chain must not restart if a caller passes a new callback identity. + // Synced in an effect (declared before the chain below, so it lands first on mount) because + // a render-phase write can leak from a render React discards. + const onOnboardingLoadedRef = useRef(onOnboardingLoaded) + useEffect(() => { + onOnboardingLoadedRef.current = onOnboardingLoaded + }, [onOnboardingLoaded]) + + useEffect(() => installCodexDetachedPaneRestartExecutor(), []) + + // Fetch initial data + hydrate GitHub cache from disk + useEffect(() => { + let cancelled = false + // Why: declared outside the async block so cleanup can abort it — under StrictMode the first (unmounted) pass would otherwise keep spawning PTYs. + const abortController = new AbortController() + + // Why (issue #1158): hydrate persisted UI right after ui.get() succeeds; the UI writer is gated only on persistedUIReady, so later default fallback would serialize defaults to disk. + let uiHydrated = false + // Why (issue #1158): track whether success-path reconnect started so the catch doesn't re-run it — re-entering on partially-mutated state would double-set ptyIds and drain pending* twice. + let reconnectStarted = false + void (async () => { + const startupStartedAt = performance.now() + logRendererStartupDiagnostic('startup-chain-start') + try { + // Why: nothing in the hydration chain reads profile state synchronously, so don't let it add a serial IPC round-trip before fetchSettings. + void actions.fetchOrcaProfiles() + // Why: repo/worktree hydration routes through settings.activeRuntimeEnvironmentId; load settings first so a persisted remote runtime doesn't hydrate stale local state. + await timeRendererStartupStep('fetch-settings', () => actions.fetchSettings()) + // Why: hidden-at-launch PTYs can query OSC 10/11 before any pane mounts; publish view attributes as soon as settings exist so main's silent-until-push responder has data. + publishTerminalViewAttributesAtAppStart( + useAppStore.getState().settings, + getSystemPrefersDark() + ) + // Why: start keybindings + onboarding now so their IPC overlaps the local catalog scans; await them at their original spots. The .catch marks rejections handled if an earlier await throws first. + // Why: browser session profiles are NOT started early — on a remote runtime the RPC may be unconnected and a failed fetch clears the list. + const keybindingsPromise = timeRendererStartupStep('fetch-keybindings', () => + actions.fetchKeybindings() + ) + keybindingsPromise.catch(() => {}) + const onboardingPromise = timeRendererStartupStep('onboarding-get', () => + window.api.onboarding.get() + ) + onboardingPromise.catch(() => {}) + // Why: await ui.get() (not overlap) so persisted view settings hydrate before the local catalog/session steps and first paint reflects them. + const persistedUI = await timeRendererStartupStep('ui-get', () => window.api.ui.get()) + uiHydrated = timeRendererStartupSyncStep('hydrate-persisted-ui', () => + hydratePersistedUIAfterStartupRead({ + persistedUI, + cancelled, + hydratePersistedUI: actions.hydratePersistedUI + }) + ) + // Why: list-runtime-session-hosts reads no repo state, so overlap it with the repo scan + // instead of paying its IPC round-trip serially before repos. .catch marks rejections handled + // if an earlier await throws first; the value is awaited below and surfaces any error there. + const runtimeHostsPromise = timeRendererStartupStep( + 'list-runtime-session-hosts', + listRuntimeSessionHostIdsForStartup + ) + runtimeHostsPromise.catch(() => {}) + // Why: saved remote runtimes can spend the full connect timeout; load only the local catalog for first paint and refresh remotes after hydration. + await timeRendererStartupStep('fetch-repos-local', () => + actions.fetchReposForAllHosts({ remoteHosts: 'skip' }) + ) + await timeRendererStartupStep('repo-catalog-settlement', () => + actions.awaitLocalRepoCatalogSettlement() + ) + // Why: folder workspaces merge against projectGroups (repos.ts fetchFolderWorkspacesForAllHosts), + // so keep this chain ordered while overlapping it with session-scoped hydration. + const localCatalogChain = (async () => { + await timeRendererStartupStep('fetch-project-groups-local', () => + actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' }) + ) + await timeRendererStartupStep('fetch-folder-workspaces-local', () => + actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' }) + ) + })() + const sessionReadPromise = runtimeHostsPromise.then((startupRuntimeHostIds) => + // Why: include saved runtime host ids so per-host worktree session slices restore from local settings without waiting on network reachability; unreadable partitions skip. + timeRendererStartupStep('session-get', () => + fetchWorkspaceSessionWithRuntimeHostOwners( + window.api.session, + useAppStore.getState().repos, + startupRuntimeHostIds + ) + ) + ) + const hydrationSessionChain = sessionReadPromise.then(async (sessionRead) => { + const hydrationRepoIds = collectWorktreeHydrationRepoIdsFromSession( + sessionRead.session, + sessionRead.runtimeHostIdByWorkspaceSessionKey + ) + const hydrationRepoIdSet = new Set(hydrationRepoIds) + const hydrationRepos = useAppStore.getState().repos.filter( + (repo) => + hydrationRepoIdSet.has(repo.id) && + // Why: disconnected SSH repos hydrate from local metadata; only runtime-owned repos use placeholders. + parseExecutionHostId(getRepoExecutionHostId(repo))?.kind !== 'runtime' + ) + // Why: worktree refresh can spawn host Git; wait for main's shell-PATH generation fence first. + await timeRendererStartupStep('first-window-services-await', () => + window.api.app.awaitFirstWindowStartupServices() + ) + await timeRendererStartupStep('fetch-hydration-worktrees', () => + mapWithConcurrency(hydrationRepos, WORKTREE_REFRESH_CONCURRENCY, (repo) => + actions.fetchWorktrees(repo.id, { executionHostId: getRepoExecutionHostId(repo) }) + ) + ) + return sessionRead + }) + // Why: wait for both writers to settle before recovery so neither can mutate hydrated state afterward. + const [sessionOutcome, catalogOutcome] = await Promise.allSettled([ + hydrationSessionChain, + localCatalogChain + ]) + if (sessionOutcome.status === 'rejected') { + throw sessionOutcome.reason + } + if (catalogOutcome.status === 'rejected') { + throw catalogOutcome.reason + } + const sessionRead = sessionOutcome.value + await keybindingsPromise + await timeRendererStartupStep('repo-catalog-final-settlement', () => + actions.awaitLocalRepoCatalogSettlement() + ) + if (!cancelled) { + const sessionHydrationOptions = { + additionalValidWorkspaceKeys: collectFolderWorkspaceKeysFromSession(sessionRead.session) + } + timeRendererStartupSyncStep('hydrate-session-stores', () => { + actions.hydrateWorkspaceSession(sessionRead.session, { + ...sessionHydrationOptions, + runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey + }) + actions.hydrateTabsSession(sessionRead.session, sessionHydrationOptions) + actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions) + actions.hydrateBrowserSession(sessionRead.session, sessionHydrationOptions) + }) + // Why: prune visit timestamps AFTER hydration (earlier, worktreesByRepo may be empty and prune would drop entries for worktrees about to appear); seed the active worktree if missing. + // See docs/cmd-j-empty-query-ordering.md. + timeRendererStartupSyncStep('visit-timestamp-prune', () => { + actions.pruneLastVisitedTimestamps() + actions.seedActiveWorktreeLastVisitedIfMissing() + }) + await timeRendererStartupStep('fetch-browser-session-profiles', () => + actions.fetchBrowserSessionProfiles() + ) + const onboardingState = await onboardingPromise + if (!cancelled) { + onOnboardingLoadedRef.current(onboardingState) + } + + // Why: re-establish SSH before terminal reconnect so SSH-backed tabs route through pty.attach; passphrase targets defer to tab focus to avoid stacked credential dialogs. + // Why: never dial runtime-owned (ephemeral-VM) targets from the renderer — ssh.connect would dispose the runtime layer's live relay session; filter them out here too. + const connectionIds = (sessionRead.session.activeConnectionIdsAtShutdown ?? []).filter( + (targetId) => !isRuntimeOwnedSshTargetId(targetId) + ) + if (connectionIds.length > 0) { + try { + await restoreSshConnectionsForStartup({ + connectionIds, + setDeferredSshReconnectTargets: actions.setDeferredSshReconnectTargets, + publishSshConnectionState: actions.setSshConnectionState + }) + } catch (err) { + console.warn('SSH startup reconnect failed:', err) + } + } else { + logRendererStartupDiagnostic('ssh-reconnect-skipped', { connectionIds: 0 }) + } + + // first-window-services-await already fenced worktree hydration; terminal recovery reuses that ready state. + await timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect', () => + window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() + ) + await timeRendererStartupStep('terminal-provider-snapshot-capabilities', () => { + return refreshTerminalProviderSnapshotCapabilities( + collectTerminalProviderSnapshotPtyIds(useAppStore.getState()) + ) + }) + reconnectStarted = true + await timeRendererStartupStep('reconnect-terminals', () => + actions.reconnectPersistedTerminals(abortController.signal) + ) + await timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect', () => + window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() + ) + // Why here: reconnect just published restored PTY ids; sweeping them now + // re-offers stale Codex panes whose tabs never mount this session. + sweepRestoredCodexPanesForStaleAccounts(useAppStore.getState()) + syncZoomCSSVar() + // Why (issue #1158): unlock the session writer only after hydration and all dependent steps succeeded, so a mid-startup throw can't serialize partially-mutated state to disk. + actions.setHydrationSucceeded(true) + logRendererStartupDiagnostic('startup-hydration-done', { + durationMs: Math.round(performance.now() - startupStartedAt) + }) + void (async () => { + try { + try { + await timeRendererStartupStep('remote-catalog-refresh', async () => { + await actions.fetchReposForAllHosts() + await actions.fetchProjectGroupsForAllHosts() + await actions.fetchFolderWorkspacesForAllHosts() + }) + } catch (err) { + console.warn('Remote startup catalog refresh failed:', err) + } + if (!cancelled) { + try { + await timeRendererStartupStep('remote-worktree-refresh', async () => { + // Why: the full scan is not required for session recovery, so keep it off the startup-critical path. + await actions.fetchAllWorktrees() + // Why: the startup prune only saw session-referenced repos; use the deferred scan's + // authoritative results to drop deleted-worktree visit timestamps that would + // otherwise accumulate unbounded (disconnected SSH stays non-authoritative and is kept). + actions.pruneLastVisitedTimestamps() + await actions.fetchWorktreeLineage() + }) + } catch (err) { + console.warn('Deferred startup worktree refresh failed:', err) + } + } + } finally { + if (!cancelled) { + useAppStore.setState({ startupWorktreeRefreshCompleted: true }) + } + } + })() + } + } catch (error) { + await recoverFromDegradedStartup({ + error, + uiHydrated, + reconnectStarted, + isCancelled: () => cancelled, + hydratePersistedUI: actions.hydratePersistedUI, + reconnectPersistedTerminals: actions.reconnectPersistedTerminals, + abortSignal: abortController.signal + }) + } + void actions.initGitHubCache() + })() + + return () => { + cancelled = true + abortController.abort() + } + }, [actions]) +} diff --git a/src/renderer/src/app-shell/use-document-appearance.ts b/src/renderer/src/app-shell/use-document-appearance.ts new file mode 100644 index 00000000000..7f0c3f8af72 --- /dev/null +++ b/src/renderer/src/app-shell/use-document-appearance.ts @@ -0,0 +1,41 @@ +import { useEffect } from 'react' +import { buildAppFontFamily } from '@/lib/app-font-family' +import { applyDocumentTheme } from '../lib/document-theme' +import { scheduleRuntimeGraphSync } from '../runtime/sync-runtime-graph' +import { useAppStore } from '../store' + +/** Applies the settings-driven theme and app font to the document root. */ +export function useDocumentAppearance(): void { + const settings = useAppStore((s) => s.settings) + + useEffect(() => { + if (!settings) { + return + } + + if (settings.theme === 'dark') { + applyDocumentTheme('dark') + return undefined + } else if (settings.theme === 'light') { + applyDocumentTheme('light') + return undefined + } + // system + const mq = window.matchMedia('(prefers-color-scheme: dark)') + applyDocumentTheme('system') + const handler = (): void => { + applyDocumentTheme('system') + // System theme changes don't mutate the store, so mobile terminal colors need an explicit graph republish. + scheduleRuntimeGraphSync() + } + mq.addEventListener('change', handler) + return () => mq.removeEventListener('change', handler) + }, [settings]) + + useEffect(() => { + document.documentElement.style.setProperty( + '--app-font-family', + buildAppFontFamily(settings?.appFontFamily) + ) + }, [settings?.appFontFamily]) +} diff --git a/src/renderer/src/app-shell/use-floating-workspace-panel.ts b/src/renderer/src/app-shell/use-floating-workspace-panel.ts new file mode 100644 index 00000000000..49daaec55d8 --- /dev/null +++ b/src/renderer/src/app-shell/use-floating-workspace-panel.ts @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useRef, useState, type SetStateAction } from 'react' +import { + TOGGLE_FLOATING_TERMINAL_EVENT, + requestFloatingTerminalOpenMaximized +} from '@/lib/floating-terminal' +import { createFloatingWorkspaceTourInteractionSnapshot } from '@/lib/floating-workspace-tour-interaction-snapshot' +import { + persistFloatingTerminalPanelOpen, + readPersistedFloatingTerminalPanelViewState +} from '../components/floating-terminal/floating-terminal-panel-view-state' +import { useAppStore } from '../store' +import { selectFloatingVisibleTabCount } from '../store/selectors' + +export type FloatingWorkspacePanelState = ReturnType + +/** + * Owns the floating workspace overlay's open state: persistence, the focus it hands back on + * close, and the toggle/disable paths that can flip it from outside React. + */ +export function useFloatingWorkspacePanel() { + // Why restored: leaving the panel closed forces the user to reopen and re-maximize it, + // and that size jump reflows a live TUI's buffer (see floating-terminal-panel-view-state). + const [open, setOpen] = useState( + () => readPersistedFloatingTerminalPanelViewState()?.open === true + ) + const tourInteractionSnapshotRef = useRef<{ + wasPreviouslyInteracted?: boolean + persisted?: Promise + recordFeatureInteractionForTour: boolean + } | null>(null) + + const enabled = useAppStore((s) => s.settings?.floatingTerminalEnabled === true) + // Why tracked separately: the flag reads false while settings are still loading, and a + // false read at boot must not be treated as the user disabling the feature. The store + // initializes `settings` to null (not undefined) - fetchSettings replaces it atomically. + const settingsHydrated = useAppStore((s) => s.settings != null) + const triggerLocation = useAppStore( + (s) => s.settings?.floatingTerminalTriggerLocation ?? 'floating-button' + ) + const statusBarVisible = useAppStore((s) => s.statusBarVisible) + const visibleTabCount = useAppStore(selectFloatingVisibleTabCount) + + // Why: floating workspace is a transient overlay; hotkey minimize returns focus to the surface the user came from. + const returnFocusRef = useRef(null) + const returnFocusFrameRef = useRef(null) + + const cancelReturnFocusFrame = useCallback((): void => { + if (returnFocusFrameRef.current === null) { + return + } + cancelAnimationFrame(returnFocusFrameRef.current) + returnFocusFrameRef.current = null + }, []) + + const rememberReturnFocus = useCallback((): void => { + const active = document.activeElement + if (!(active instanceof HTMLElement)) { + returnFocusRef.current = null + return + } + if ( + active.closest('[data-floating-terminal-panel]') || + active.closest('[data-floating-terminal-toggle]') + ) { + return + } + returnFocusRef.current = active + }, []) + + const restoreReturnFocus = useCallback((): void => { + const target = returnFocusRef.current + returnFocusRef.current = null + if (!target || !document.contains(target)) { + return + } + cancelReturnFocusFrame() + returnFocusFrameRef.current = requestAnimationFrame(() => { + returnFocusFrameRef.current = null + if (!document.contains(target)) { + return + } + target.focus({ preventScroll: true }) + }) + }, [cancelReturnFocusFrame]) + + const setOpenWithFocus = useCallback( + (nextOpen: SetStateAction): void => { + const resolvedOpen = typeof nextOpen === 'function' ? nextOpen(open) : nextOpen + // Why: recordFeatureInteraction updates Zustand subscribers; running it inside the state updater logs a render-phase update warning. + if (resolvedOpen && !open) { + tourInteractionSnapshotRef.current = createFloatingWorkspaceTourInteractionSnapshot( + useAppStore.getState() + ) + rememberReturnFocus() + } else if (!resolvedOpen && open) { + restoreReturnFocus() + } + setOpen(resolvedOpen) + // Why gated on the flag: `settings` is undefined until it hydrates, so the + // feature-off effect force-closes the panel on every boot. Persisting there would + // overwrite the user's restored `open` with a value they never chose. + if (enabled) { + persistFloatingTerminalPanelOpen(resolvedOpen) + } + }, + [enabled, open, rememberReturnFocus, restoreReturnFocus] + ) + + const openMaximized = useCallback((): void => { + requestFloatingTerminalOpenMaximized() + setOpenWithFocus(true) + }, [setOpenWithFocus]) + + useEffect(() => { + const toggleFloatingTerminal = (): void => { + if (enabled) { + setOpenWithFocus((current) => !current) + } + } + window.addEventListener(TOGGLE_FLOATING_TERMINAL_EVENT, toggleFloatingTerminal) + return () => window.removeEventListener(TOGGLE_FLOATING_TERMINAL_EVENT, toggleFloatingTerminal) + }, [enabled, setOpenWithFocus]) + + useEffect(() => { + // Why the hydration gate: this effect fires on every boot while settings are still + // undefined, and closing there discards the restored open state before the real flag + // value arrives. Only a hydrated flag-off is an actual disable. + if (settingsHydrated && !enabled) { + setOpenWithFocus(false) + } + }, [settingsHydrated, enabled, setOpenWithFocus]) + + return { + cancelReturnFocusFrame, + enabled, + open, + openMaximized, + setOpenWithFocus, + // Why: once the floating workspace owns tabs, keep it mounted while closed so hidden terminal/browser/editor panes retain local state. + shouldMountPanel: enabled && (open || visibleTabCount > 0), + showToggleButton: enabled && (triggerLocation === 'floating-button' || !statusBarVisible), + tourInteractionSnapshotRef, + visibleTabCount + } +} diff --git a/src/renderer/src/app-shell/use-global-keybindings.ts b/src/renderer/src/app-shell/use-global-keybindings.ts new file mode 100644 index 00000000000..d40d928a20c --- /dev/null +++ b/src/renderer/src/app-shell/use-global-keybindings.ts @@ -0,0 +1,322 @@ +import { useEffect, useLayoutEffect, useRef } from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility' +import { isEditableTarget } from '../lib/editable-target' +import { getSelectedTextForFileSearch } from '../lib/file-search-selection' +import { registerAppCommandDispatcher } from '@/lib/app-command-dispatch' +import { executePluginCommand } from '@/lib/plugin-command-execution' +import { findPluginCommandForKeybinding } from '@/lib/plugin-command-keybindings' +import { + isFloatingWorkspacePanelFocused, + isFloatingWorkspaceTerminalInputTarget, + matchFloatingWorkspacePanelChord, + shouldMinimizeFloatingWorkspacePanelOnCloseShortcut +} from '@/lib/floating-workspace-terminal-actions' +import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' +import { + folderRelativePathToIncludeGlob, + selectedExplorerFolderRelativePath +} from '../components/right-sidebar/file-search-include-pattern' +import { usePluginCommands } from '@/store/plugin-panels' +import { useAppStore } from '../store' +import { + keybindingMatchesAction, + type KeybindingActionId, + type KeybindingMatchOptions +} from '../../../shared/keybindings' +import { PLUGIN_COMMAND_ALIAS_ACTION_IDS } from '../../../shared/plugins/plugin-command-actions' +import { + ModifierDoubleTapDetector, + toModifierDoubleTapEvent +} from '../../../shared/modifier-double-tap-detector' +import { shortcutPlatform } from './app-window-chrome' +import { + createAppCommandHandlers, + getKeybindingContext, + useAppShortcutActions, + type AppShortcutState, + type ShortcutDispatchInput +} from './app-command-handlers' +import type { AppChromeLayout } from './use-app-chrome-layout' +import type { FloatingWorkspacePanelState } from './use-floating-workspace-panel' + +/** + * Registers the window-level shortcut listeners and the app command dispatcher. + * + * Window key listeners are global and long-lived: one registration, but the handler reads + * current shortcut state each key event through a ref. + */ +export function useGlobalKeybindings(args: { + layout: AppChromeLayout + floatingWorkspace: FloatingWorkspacePanelState +}): void { + const { layout, floatingWorkspace } = args + const actions = useAppShortcutActions() + const keybindings = useAppStore((s) => s.keybindings) + const terminalShortcutPolicy = useAppStore((s) => s.settings?.terminalShortcutPolicy) + const pluginCommands = usePluginCommands() + + const shortcutState: AppShortcutState = { + activeView: layout.activeView, + activeWorktreeId: layout.activeWorktreeId, + actions, + creationLayoutActive: layout.creationLayoutActive, + floatingTerminalEnabled: floatingWorkspace.enabled, + floatingTerminalOpen: floatingWorkspace.open, + floatingVisibleTabCount: floatingWorkspace.visibleTabCount, + keybindings, + openFloatingWorkspaceMaximized: floatingWorkspace.openMaximized, + pluginCommands, + setFloatingTerminalOpen: floatingWorkspace.setOpenWithFocus, + terminalShortcutPolicy, + workspaceChromeActive: layout.workspaceChromeActive + } + const shortcutStateRef = useRef(shortcutState) + // Why useLayoutEffect: the mirror must be current before any key event can read it, and a + // render-phase write would also publish state from a render React discards. Key events are + // discrete, so they always observe the committed value. + useLayoutEffect(() => { + shortcutStateRef.current = shortcutState + }) + + useEffect(() => { + const doubleTapDetector = new ModifierDoubleTapDetector() + + const unregisterAppCommandDispatcher = registerAppCommandDispatcher((actionId) => + (createAppCommandHandlers(shortcutStateRef.current).get(actionId) ?? (() => false))() + ) + + const dispatchShortcutInput = (input: ShortcutDispatchInput): void => { + const state = shortcutStateRef.current + const { + activeView, + activeWorktreeId, + actions, + creationLayoutActive, + floatingTerminalEnabled, + floatingTerminalOpen, + floatingVisibleTabCount, + keybindings, + openFloatingWorkspaceMaximized, + pluginCommands, + setFloatingTerminalOpen, + terminalShortcutPolicy + } = state + + // Child handlers (e.g. terminal search) share this window capture phase and fire first; bail if they already preventDefault'd so both don't act. + if (input.defaultPrevented) { + return + } + // The Settings shortcut recorder captures existing shortcuts, so global handlers must not fire while its button has focus. + if ( + input.target instanceof Element && + input.target.closest('[data-shortcut-recorder-active]') !== null + ) { + return + } + const context = getKeybindingContext(input.target) + + // Note: some shortcuts are also intercepted in createMainWindow.ts before-input-event (for browser-guest focus); the renderer keeps handlers for local focus. + + const matchShortcut = (actionId: KeybindingActionId): boolean => + keybindingMatchesAction(actionId, input, shortcutPlatform, keybindings, { + context, + terminalShortcutPolicy + }) + const notifyTerminalCapture = (actionId: KeybindingActionId): void => { + if (context !== 'terminal' || (terminalShortcutPolicy ?? 'orca-first') !== 'orca-first') { + return + } + showTerminalShortcutCaptureNotification({ + actionId, + platform: shortcutPlatform, + keybindings + }) + } + + const canRevealRightSidebar = !creationLayoutActive && canShowRightSidebarForView(activeView) + + if (matchShortcut('sidebar.search.toggle') && canRevealRightSidebar) { + // With a folder selected in the explorer, Cmd/Ctrl+Shift+F means "Find in Folder" — seed the include pattern with it, not a text search. + const selectedFolderRelativePath = + document.activeElement instanceof Element + ? selectedExplorerFolderRelativePath(document.activeElement) + : null + if (selectedFolderRelativePath !== null && activeWorktreeId) { + input.preventDefault() + notifyTerminalCapture('sidebar.search.toggle') + actions.showRightSidebarSearch({ + includePattern: folderRelativePathToIncludeGlob(selectedFolderRelativePath) + }) + return + } + + const selectedText = getSelectedTextForFileSearch() + if (selectedText) { + input.preventDefault() + notifyTerminalCapture('sidebar.search.toggle') + actions.showRightSidebarSearch({ query: selectedText }) + return + } + } + + // An empty floating workspace has no tab to close, so Cmd/Ctrl+W hides the overlay before other surfaces act. + if ( + keybindingMatchesAction('tab.close', input, shortcutPlatform, keybindings, { + context: 'app' + }) && + shouldMinimizeFloatingWorkspacePanelOnCloseShortcut({ + floatingTerminalOpen, + floatingVisibleTabCount + }) + ) { + input.preventDefault() + setFloatingTerminalOpen(false) + return + } + + // Floating panel closed → its keydown handler is gone, so honor the maximize chord here by opening it pre-maximized (no-op while it's open). + if ( + !floatingTerminalOpen && + matchShortcut('floatingWorkspace.maximize') && + floatingTerminalEnabled + ) { + input.preventDefault() + openFloatingWorkspaceMaximized() + return + } + + // Skip editable surfaces so TipTap's Cmd+B bold works; this renderer-side fallback covers the blur→press IPC race (docs/markdown-cmd-b-bold-design.md). + if (isEditableTarget(input.target)) { + return + } + + // Let floating-terminal SSH/tmux control chords reach the terminal (xterm's helper textarea isn't a generic editable target). + if (isFloatingWorkspaceTerminalInputTarget(input.target)) { + return + } + + // Only short-circuit chords the floating panel itself claims; suppressing others here would silently no-op them when focus is in the panel. + if (isFloatingWorkspacePanelFocused()) { + const floatingMatchOptions: KeybindingMatchOptions = { context, terminalShortcutPolicy } + if ( + matchFloatingWorkspacePanelChord( + input, + shortcutPlatform, + null, + keybindings, + floatingMatchOptions + ) !== null + ) { + return + } + } + + // Plugin chords are user-reviewed instructional content. They win over + // built-in defaults only in app focus; terminal/editor/browser handlers + // retain their own shortcut authority. + if (context === 'app') { + const pluginCommand = findPluginCommandForKeybinding( + pluginCommands, + input, + shortcutPlatform, + keybindings, + Boolean(activeWorktreeId) + ) + if (pluginCommand) { + input.preventDefault() + void executePluginCommand(pluginCommand, 'plugin-keybinding').catch(() => { + toast.error( + translate('auto.App.pluginCommandFailed', 'Could not run the plugin command.') + ) + }) + return + } + } + + const handlers = createAppCommandHandlers(state, input, context) + for (const actionId of PLUGIN_COMMAND_ALIAS_ACTION_IDS) { + if (matchShortcut(actionId) && handlers.get(actionId)?.()) { + return + } + } + + // Unbound by default, so it runs after the built-in alias handlers above; only consumes the chord when the active worktree has unsent notes. + if (canRevealRightSidebar && matchShortcut('sourceControl.sendReviewNotes')) { + if (actions.openDiffNotesSendMenuForActiveWorktree()) { + input.preventDefault() + notifyTerminalCapture('sourceControl.sendReviewNotes') + } + } + } + + const onKeyDown = (e: KeyboardEvent): void => { + const detected = doubleTapDetector.process( + toModifierDoubleTapEvent({ + type: 'keyDown', + code: e.code, + key: e.key, + shift: e.shiftKey, + control: e.ctrlKey, + alt: e.altKey, + meta: e.metaKey, + isAutoRepeat: e.repeat + }), + Date.now() + ) + if (e.repeat) { + return + } + if (detected) { + // Synthetic input: no key/modifier flags, so only DoubleTap bindings match. + dispatchShortcutInput({ + doubleTapModifier: detected.modifier, + target: e.target, + defaultPrevented: e.defaultPrevented, + preventDefault: () => e.preventDefault() + }) + return + } + dispatchShortcutInput({ + key: e.key, + code: e.code, + altKey: e.altKey, + metaKey: e.metaKey, + ctrlKey: e.ctrlKey, + shiftKey: e.shiftKey, + target: e.target, + defaultPrevented: e.defaultPrevented, + preventDefault: () => e.preventDefault() + }) + } + + const onKeyUp = (e: KeyboardEvent): void => { + doubleTapDetector.process( + toModifierDoubleTapEvent({ + type: 'keyUp', + code: e.code, + key: e.key, + shift: e.shiftKey, + control: e.ctrlKey, + alt: e.altKey, + meta: e.metaKey + }), + Date.now() + ) + } + + // Why: a window blur mid-gesture must not leave the detector armed. + const onBlur = (): void => doubleTapDetector.reset() + + window.addEventListener('keydown', onKeyDown, { capture: true }) + window.addEventListener('keyup', onKeyUp, { capture: true }) + window.addEventListener('blur', onBlur) + return () => { + unregisterAppCommandDispatcher() + window.removeEventListener('keydown', onKeyDown, { capture: true }) + window.removeEventListener('keyup', onKeyUp, { capture: true }) + window.removeEventListener('blur', onBlur) + } + }, []) +} diff --git a/src/renderer/src/app-shell/use-lazy-modal-mounts.ts b/src/renderer/src/app-shell/use-lazy-modal-mounts.ts new file mode 100644 index 00000000000..1f505c583fb --- /dev/null +++ b/src/renderer/src/app-shell/use-lazy-modal-mounts.ts @@ -0,0 +1,48 @@ +import { useEffect, useRef, useState } from 'react' +import { resolveMountedLazyModalIds, type LazyModalId } from '../lazy-modal-mount-state' +import { useAppStore } from '../store' + +/** + * Tracks which lazy modal chunks are mounted. Modals load on first use and then stay mounted + * so repeat opens preserve state and avoid re-fetch flashes. + */ +export function useLazyModalMounts(): { + mountedLazyModalIds: ReadonlySet + shouldMountAddRepoDialog: boolean +} { + const activeModal = useAppStore((s) => s.activeModal) + const [mountedLazyModalIds, setMountedLazyModalIds] = useState>(() => new Set()) + const [shouldMountAddRepoDialog, setShouldMountAddRepoDialog] = useState(false) + const unmountAddRepoDialogTimerRef = useRef | null>(null) + + useEffect(() => { + if (activeModal === 'add-repo') { + if (unmountAddRepoDialogTimerRef.current) { + clearTimeout(unmountAddRepoDialogTimerRef.current) + unmountAddRepoDialogTimerRef.current = null + } + setShouldMountAddRepoDialog(true) + return + } + if (shouldMountAddRepoDialog && !unmountAddRepoDialogTimerRef.current) { + // Why: AddRepoDialog's close effect aborts in-flight clone work; keep one closed render before unmounting hidden SSH/remote subscriptions. + unmountAddRepoDialogTimerRef.current = setTimeout(() => { + setShouldMountAddRepoDialog(false) + unmountAddRepoDialogTimerRef.current = null + }, 0) + } + return () => { + if (unmountAddRepoDialogTimerRef.current) { + clearTimeout(unmountAddRepoDialogTimerRef.current) + unmountAddRepoDialogTimerRef.current = null + } + } + }, [activeModal, shouldMountAddRepoDialog]) + + const resolvedMountedLazyModalIds = resolveMountedLazyModalIds(activeModal, mountedLazyModalIds) + if (resolvedMountedLazyModalIds !== mountedLazyModalIds) { + setMountedLazyModalIds(new Set(resolvedMountedLazyModalIds)) + } + + return { mountedLazyModalIds: resolvedMountedLazyModalIds, shouldMountAddRepoDialog } +} diff --git a/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts b/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts new file mode 100644 index 00000000000..dc689fe1565 --- /dev/null +++ b/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts @@ -0,0 +1,141 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { onOnboardingReopened } from '../components/onboarding/show-onboarding-event' +import { shouldShowOnboarding } from '../components/onboarding/should-show-onboarding' +import { + getFeatureTipsAppOpenDecision, + isCliFeatureTipCompleted +} from '../components/feature-tips/feature-tip-startup-gate' +import { + trackCmdJPaletteFeatureTipShown, + trackOrcaCliFeatureTipShown +} from '../components/feature-tips/feature-tip-telemetry' +import { useAppStore } from '../store' +import type { OnboardingState } from '../../../shared/onboarding-state-types' + +export type OnboardingGate = ReturnType + +/** + * Owns first-run education: the onboarding flow's visibility plus the one-per-session + * feature-tip prompt, which stays suppressed until onboarding's state is known. + */ +export function useOnboardingAndFeatureTips() { + const [onboarding, setOnboarding] = useState(null) + const [onboardingLoaded, setOnboardingLoaded] = useState(false) + const [featureTipCliInstalled, setFeatureTipCliInstalled] = useState(null) + const promptedThisSessionRef = useRef(false) + const suppressedByOnboardingThisSessionRef = useRef(false) + + const activeModal = useAppStore((s) => s.activeModal) + const settings = useAppStore((s) => s.settings) + const persistedUIReady = useAppStore((s) => s.persistedUIReady) + const featureTipsSeenIds = useAppStore((s) => s.featureTipsSeenIds) + const featureInteractions = useAppStore((s) => s.featureInteractions) + const contextualToursAutoEligible = useAppStore((s) => s.contextualToursAutoEligible) + const actions = useAppStore( + useShallow((s) => ({ + openModal: s.openModal, + markFeatureTipsSeen: s.markFeatureTipsSeen, + setContextualToursAutoEligible: s.setContextualToursAutoEligible, + setContextualToursOnboardingVisible: s.setContextualToursOnboardingVisible + })) + ) + + const applyStartupOnboardingState = useCallback((state: OnboardingState): void => { + setOnboarding(state) + setOnboardingLoaded(true) + }, []) + + useEffect(() => { + return onOnboardingReopened(setOnboarding) + }, []) + + useEffect(() => { + // Why: suppress tours until onboarding state is known (null = loading) so a first-run user can't mark a tour seen before onboarding appears. + const suppressTours = !onboardingLoaded || shouldShowOnboarding(onboarding) + actions.setContextualToursOnboardingVisible(suppressTours) + }, [actions, onboarding, onboardingLoaded]) + + useEffect(() => { + if (!persistedUIReady || !onboardingLoaded || contextualToursAutoEligible !== null) { + return + } + // Why: rollout targets first-run onboarding users; existing profiles are classified once and never auto-toured. + actions.setContextualToursAutoEligible(shouldShowOnboarding(onboarding)) + }, [actions, contextualToursAutoEligible, onboarding, onboardingLoaded, persistedUIReady]) + + useEffect(() => { + if (!persistedUIReady) { + return + } + + let cancelled = false + void window.api.cli + .getInstallStatus() + .then((status) => { + if (cancelled) { + return + } + setFeatureTipCliInstalled(isCliFeatureTipCompleted(status)) + }) + .catch(() => { + if (!cancelled) { + setFeatureTipCliInstalled(true) + } + }) + + return () => { + cancelled = true + } + }, [persistedUIReady]) + + useEffect(() => { + const featureTipsDecision = getFeatureTipsAppOpenDecision({ + activeModal, + cliInstalled: featureTipCliInstalled, + featureTipsSeenIds, + featureInteractions, + onboarding, + persistedUIReady, + promptedThisSession: promptedThisSessionRef.current, + settings, + suppressedByOnboardingThisSession: suppressedByOnboardingThisSessionRef.current + }) + + if (featureTipsDecision.kind === 'suppress-for-onboarding') { + // Why: first-run users should finish onboarding without a second education modal in the same session. + suppressedByOnboardingThisSessionRef.current = true + return + } + + if (featureTipsDecision.kind !== 'open') { + return + } + + promptedThisSessionRef.current = true + if (featureTipsDecision.tipId === 'orca-cli') { + trackOrcaCliFeatureTipShown('app_open') + } else if (featureTipsDecision.tipId === 'cmd-j-palette') { + trackCmdJPaletteFeatureTipShown('app_open') + } + // Why: mark seen on show so a quit/crash before dismiss doesn't reappear it next launch. + actions.markFeatureTipsSeen([featureTipsDecision.tipId]) + actions.openModal('feature-tips', { source: 'app_open', tipId: featureTipsDecision.tipId }) + }, [ + activeModal, + actions, + featureTipCliInstalled, + featureInteractions, + featureTipsSeenIds, + onboarding, + persistedUIReady, + settings + ]) + + return { + applyStartupOnboardingState, + onboarding, + setOnboarding, + shouldRender: onboarding !== null && shouldShowOnboarding(onboarding) + } +} diff --git a/src/renderer/src/app-shell/use-persisted-ui-writer.ts b/src/renderer/src/app-shell/use-persisted-ui-writer.ts new file mode 100644 index 00000000000..30b82da76b3 --- /dev/null +++ b/src/renderer/src/app-shell/use-persisted-ui-writer.ts @@ -0,0 +1,70 @@ +import { useEffect } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '../store' + +/** + * Mirrors the sidebar/right-sidebar/filter preferences into the durable UI file. + * + * Why (#9002): activeView is deliberately kept off this debounced writer. It used to ride the + * same 150ms save (#8265), so every top-level view switch scheduled a full durable-state write. + */ +export function usePersistedUIWriter(): void { + const persistedUIReady = useAppStore((s) => s.persistedUIReady) + const activeView = useAppStore((s) => s.activeView) + const ui = useAppStore( + useShallow((s) => ({ + sidebarWidth: s.sidebarWidth, + rightSidebarOpen: s.rightSidebarOpen, + rightSidebarTab: s.rightSidebarTab, + rightSidebarExplorerView: s.rightSidebarExplorerView, + rightSidebarWidth: s.rightSidebarWidth, + markdownTocPanelWidth: s.markdownTocPanelWidth, + combinedDiffFileTreeWidth: s.combinedDiffFileTreeWidth, + groupBy: s.groupBy, + sortBy: s.sortBy, + projectOrderBy: s.projectOrderBy, + showSleepingWorkspaces: s.showSleepingWorkspaces, + hideDefaultBranchWorkspace: s.hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces: s.hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces: s.hideCliCreatedWorkspaces, + hideDetachedHeadWorkspaces: s.hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices: s.hideWorkspacesFromOtherDevices, + alwaysShowDefaultBranchWorkspace: s.alwaysShowDefaultBranchWorkspace, + showDotfilesByWorktree: s.showDotfilesByWorktree, + filterRepoIds: s.filterRepoIds, + // Why: rides the same debounced save so dashboard auto-acks (which fire on focus/ + // visibility) and the in-memory ack cleanup paths in agent-status.ts (close/dismiss) + // both flow to disk through map identity changes. Without persisting, agent rows that + // survive restart come back bold even when the user had already visited them. + acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey + })) + ) + + useEffect(() => { + if (!persistedUIReady) { + return + } + + const timer = window.setTimeout(() => { + void window.api.ui.set({ + ...ui, + showActiveOnly: false, + hideSleepingWorkspaces: !ui.showSleepingWorkspaces, + // Why: the store keeps this readonly for identity stability, but PersistedUI crosses to + // main, which owns a mutable array — copy at the boundary rather than widening the wire type. + filterRepoIds: [...ui.filterRepoIds] + }) + }, 150) + + return () => window.clearTimeout(timer) + }, [persistedUIReady, ui]) + + // Why (#9002): activeView has its own tiny profile preference, so it can track + // every switch without scheduling the multi-MB durable-state writer. + useEffect(() => { + if (!persistedUIReady) { + return + } + void window.api.ui.set({ activeView }) + }, [activeView, persistedUIReady]) +} diff --git a/src/renderer/src/app-shell/use-runtime-graph-sync.ts b/src/renderer/src/app-shell/use-runtime-graph-sync.ts new file mode 100644 index 00000000000..96b7b56d59c --- /dev/null +++ b/src/renderer/src/app-shell/use-runtime-graph-sync.ts @@ -0,0 +1,60 @@ +import { useEffect } from 'react' +import { getSystemPrefersDarkSnapshot } from '../components/terminal-pane/use-system-prefers-dark' +import { + canSkipRuntimeMobileSessionSyncKeyBuild, + getRuntimeMobileSessionSyncKey, + runtimeMobileSessionSyncKeysEqual, + scheduleRuntimeGraphSync, + setRuntimeGraphStoreStateGetter, + setRuntimeGraphSyncEnabled +} from '../runtime/sync-runtime-graph' +import { useAppStore } from '../store' + +/** Keeps the mobile runtime graph republished as the store's session-visible state changes. */ +export function useRuntimeGraphSync(): void { + const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) + + useEffect(() => { + setRuntimeGraphStoreStateGetter(useAppStore.getState) + return () => { + setRuntimeGraphStoreStateGetter(null) + } + }, []) + + useEffect(() => { + let previousKey = getRuntimeMobileSessionSyncKey(useAppStore.getState()) + return useAppStore.subscribe((state, previousState) => { + // Why: this fires on every store mutation; read the cached prefers-dark snapshot instead of allocating a throwaway MediaQueryList via matchMedia each tick. + const systemPrefersDark = getSystemPrefersDarkSnapshot() + // Why: skip the key build when every input is reference-unchanged; the gate mirrors every field getRuntimeMobileSessionSyncKey uses. + if ( + canSkipRuntimeMobileSessionSyncKeyBuild( + state, + previousState, + systemPrefersDark, + previousKey.systemPrefersDark + ) + ) { + return + } + const nextKey = getRuntimeMobileSessionSyncKey( + state, + previousState, + previousKey, + systemPrefersDark + ) + if (runtimeMobileSessionSyncKeysEqual(nextKey, previousKey)) { + return + } + previousKey = nextKey + scheduleRuntimeGraphSync() + }) + }, []) + + useEffect(() => { + setRuntimeGraphSyncEnabled(workspaceSessionReady) + return () => { + setRuntimeGraphSyncEnabled(false) + } + }, [workspaceSessionReady]) +} diff --git a/src/renderer/src/app-shell/use-window-visibility-effects.ts b/src/renderer/src/app-shell/use-window-visibility-effects.ts new file mode 100644 index 00000000000..bb8ec640f10 --- /dev/null +++ b/src/renderer/src/app-shell/use-window-visibility-effects.ts @@ -0,0 +1,48 @@ +import { useEffect } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' +import { useAppStore } from '../store' +import { isMac } from './app-window-chrome' + +/** Window-visibility reactions that must run app-wide, not per-surface. */ +export function useWindowVisibilityEffects(): void { + const actions = useAppStore( + useShallow((s) => ({ + refreshAllGitHub: s.refreshAllGitHub, + reportVisibleGitHubPRRefreshCandidates: s.reportVisibleGitHubPRRefreshCandidates, + bumpGitHubPRVisibleRefreshGeneration: s.bumpGitHubPRVisibleRefreshGeneration + })) + ) + + // Refresh GitHub data (PR/issue status) when window regains focus + useEffect(() => { + const handler = (): void => { + if (document.visibilityState === 'visible') { + actions.refreshAllGitHub() + actions.bumpGitHubPRVisibleRefreshGeneration() + } else { + actions.reportVisibleGitHubPRRefreshCandidates([], Date.now()) + } + } + document.addEventListener('visibilitychange', handler) + return () => document.removeEventListener('visibilitychange', handler) + }, [actions]) + + // Why (STA-2383): macOS throttles the backgrounded window; on occlusion-uncover only `focus` + // fires (invalidate-only), so the app-shell's dvh height stays stale and the bottom status bar + // is clipped off-screen until a manual resize. Relay the genuine hidden→visible reveal so main + // runs the same full repaint (size jiggle) that show/restore/resume get, recomputing the layout. + useEffect(() => { + if (!isMac || isPairedWebClientWindow()) { + return + } + const handler = (): void => { + if (document.visibilityState !== 'visible') { + return + } + window.api?.ui?.notifyWindowRevealed?.() + } + document.addEventListener('visibilitychange', handler) + return () => document.removeEventListener('visibilitychange', handler) + }, []) +} diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 5e58328306c..9f882234a51 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -2,9 +2,25 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +function readSource(relativePath: string): string { + return readFileSync(join(process.cwd(), relativePath), 'utf8') +} + +const APP_PATH = 'src/renderer/src/App.tsx' +const STARTUP_HYDRATION_PATH = 'src/renderer/src/app-shell/use-app-startup-hydration.ts' +const DEGRADED_RECOVERY_PATH = 'src/renderer/src/startup/startup-degraded-recovery.ts' +const CHROME_LAYOUT_PATH = 'src/renderer/src/app-shell/use-app-chrome-layout.ts' +const SHELL_SERVICES_PATH = 'src/renderer/src/app-shell/use-app-shell-services.ts' +const BACKGROUND_SERVICES_PATH = 'src/renderer/src/app-shell/AppBackgroundServices.tsx' +const WORKSPACE_SHELL_PATH = 'src/renderer/src/app-shell/AppWorkspaceShell.tsx' +const ROOT_SURFACES_PATH = 'src/renderer/src/app-shell/AppRootSurfaces.tsx' +const LAZY_MODAL_MOUNTS_PATH = 'src/renderer/src/app-shell/use-lazy-modal-mounts.ts' +const SESSION_PERSISTENCE_PATH = 'src/renderer/src/app-shell/use-app-session-persistence.ts' +const PERSISTED_UI_WRITER_PATH = 'src/renderer/src/app-shell/use-persisted-ui-writer.ts' + describe('renderer startup runtime routing', () => { it('hydrates persisted UI before local catalog and worktree hydration', () => { - const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const source = readSource(STARTUP_HYDRATION_PATH) const startupBlockStart = source.indexOf('void (async () => {') // Why: concurrent startup branches all settle before hydrate-session-stores. const startupBlockEnd = source.indexOf("timeRendererStartupSyncStep('hydrate-session-stores'") @@ -99,7 +115,7 @@ describe('renderer startup runtime routing', () => { }) it('refreshes remote catalogs after startup hydration succeeds', () => { - const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const source = readSource(STARTUP_HYDRATION_PATH) const hydrationDoneIndex = source.indexOf( "logRendererStartupDiagnostic('startup-hydration-done'" ) @@ -122,12 +138,13 @@ describe('renderer startup runtime routing', () => { 'actions.fetchReposForAllHosts()' ) - const startupFailureIndex = source.indexOf( + const degradedSource = readSource(DEGRADED_RECOVERY_PATH) + const startupFailureIndex = degradedSource.indexOf( '[startup] Workspace session hydration failed; leaving disk state untouched:' ) expect(startupFailureIndex).toBeGreaterThanOrEqual(0) expect( - source.indexOf('startupWorktreeRefreshCompleted: true', startupFailureIndex) + degradedSource.indexOf('startupWorktreeRefreshCompleted: true', startupFailureIndex) ).toBeGreaterThan(startupFailureIndex) expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain( 'actions.fetchProjectGroupsForAllHosts()' @@ -138,23 +155,17 @@ describe('renderer startup runtime routing', () => { }) it('waits for first-window startup services before terminal reconnect', () => { - const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') - const servicesIndex = source.indexOf('await window.api.app.awaitFirstWindowStartupServices()') + const source = readSource(STARTUP_HYDRATION_PATH) + const servicesIndex = source.indexOf("timeRendererStartupStep('first-window-services-await'") const preReconnectRecoveryIndex = source.indexOf( - 'window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()', - servicesIndex + "timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect'" ) const capabilityRefreshIndex = source.indexOf( - 'refreshTerminalProviderSnapshotCapabilities(', - preReconnectRecoveryIndex - ) - const reconnectIndex = source.indexOf( - 'actions.reconnectPersistedTerminals(abortController.signal)', - preReconnectRecoveryIndex + "timeRendererStartupStep('terminal-provider-snapshot-capabilities'" ) + const reconnectIndex = source.indexOf("timeRendererStartupStep('reconnect-terminals'") const postReconnectRecoveryIndex = source.indexOf( - 'window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()', - reconnectIndex + "timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect'" ) expect(servicesIndex).toBeGreaterThanOrEqual(0) @@ -165,7 +176,7 @@ describe('renderer startup runtime routing', () => { }) it('refreshes terminal snapshot capability before degraded reconnect', () => { - const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const source = readSource(DEGRADED_RECOVERY_PATH) const degradedStart = source.indexOf( '[startup] Workspace session hydration failed; leaving disk state untouched:' ) @@ -181,10 +192,7 @@ describe('renderer startup runtime routing', () => { 'refreshTerminalProviderSnapshotCapabilities(', recoveryIndex ) - const reconnectIndex = source.indexOf( - 'actions.reconnectPersistedTerminals(abortController.signal)', - recoveryIndex - ) + const reconnectIndex = source.indexOf('reconnectPersistedTerminals(abortSignal)', recoveryIndex) expect(degradedStart).toBeGreaterThanOrEqual(0) expect(servicesIndex).toBeGreaterThan(degradedStart) @@ -194,10 +202,7 @@ describe('renderer startup runtime routing', () => { }) it('keeps the persisted Automations view from starting its own bootstrap worktree scan', () => { - const source = readFileSync( - join(process.cwd(), 'src/renderer/src/components/automations/AutomationsPage.tsx'), - 'utf8' - ) + const source = readSource('src/renderer/src/components/automations/AutomationsPage.tsx') const fullRefreshStart = source.indexOf('const mountedBeforeStartupWorktreeRefreshRef') const fullRefreshEffect = source.slice( fullRefreshStart, @@ -210,24 +215,29 @@ describe('renderer startup runtime routing', () => { }) it('does not eagerly import the floating terminal panel on startup', () => { - const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const shellSource = readSource(WORKSPACE_SHELL_PATH) + const surfacesSource = readSource(ROOT_SURFACES_PATH) - expect(source).toContain( - "import { FloatingTerminalToggleButton } from './components/floating-terminal/FloatingTerminalToggleButton'" + expect(shellSource).toContain( + "import { FloatingTerminalToggleButton } from '../components/floating-terminal/FloatingTerminalToggleButton'" ) - expect(source).toContain("import('./components/floating-terminal/FloatingTerminalPanel').then") - expect(source).not.toContain("from './components/floating-terminal/FloatingTerminalPanel'") + expect(surfacesSource).toContain( + "import('../components/floating-terminal/FloatingTerminalPanel').then" + ) + for (const source of [shellSource, surfacesSource]) { + expect(source).not.toContain("from '../components/floating-terminal/FloatingTerminalPanel'") + } }) it('does not eagerly import idle optional overlay surfaces on startup', () => { - const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const source = readSource(ROOT_SURFACES_PATH) - expect(source).toContain("import('./components/UpdateCard').then") - expect(source).toContain("import('./components/contextual-tours/ContextualTourOverlay').then") - expect(source).toContain("import('./components/setup-guide/SetupGuideTelemetryObserver').then") - expect(source).not.toContain("from './components/UpdateCard'") - expect(source).not.toContain("from './components/contextual-tours/ContextualTourOverlay'") - expect(source).not.toContain("from './components/setup-guide/SetupGuideTelemetryObserver'") + expect(source).toContain("import('../components/UpdateCard').then") + expect(source).toContain("import('../components/contextual-tours/ContextualTourOverlay').then") + expect(source).toContain("import('../components/setup-guide/SetupGuideTelemetryObserver').then") + expect(source).not.toContain("from '../components/UpdateCard'") + expect(source).not.toContain("from '../components/contextual-tours/ContextualTourOverlay'") + expect(source).not.toContain("from '../components/setup-guide/SetupGuideTelemetryObserver'") expect(source).toContain('const shouldMountSetupGuideTelemetryObserver = persistedUIReady') expect(source).not.toContain( "const shouldMountSetupGuideTelemetryObserver = persistedUIReady && activeModal === 'setup-guide'" @@ -235,16 +245,15 @@ describe('renderer startup runtime routing', () => { }) it('keeps crash-report listeners eager while lazy-loading the dialog surface', () => { - const appSource = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') - const hostSource = readFileSync( - join(process.cwd(), 'src/renderer/src/components/crash-report/CrashReportDialog.tsx'), - 'utf8' - ) + const surfacesSource = readSource(ROOT_SURFACES_PATH) + const hostSource = readSource('src/renderer/src/components/crash-report/CrashReportDialog.tsx') - expect(appSource).toContain( - "import { CrashReportDialog } from './components/crash-report/CrashReportDialog'" + expect(surfacesSource).toContain( + "import { CrashReportDialog } from '../components/crash-report/CrashReportDialog'" + ) + expect(surfacesSource).not.toContain( + "from '../components/crash-report/CrashReportDialogSurface'" ) - expect(appSource).not.toContain("from './components/crash-report/CrashReportDialogSurface'") expect(hostSource).toContain("import('./CrashReportDialogSurface').then") expect(hostSource).toContain('window.api.crashReports.getLatestPending()') expect(hostSource).toContain('window.api.ui.onOpenCrashReport') @@ -254,10 +263,7 @@ describe('renderer startup runtime routing', () => { }) it('clears stale crash-report state before opening the lazy manual report surface', () => { - const hostSource = readFileSync( - join(process.cwd(), 'src/renderer/src/components/crash-report/CrashReportDialog.tsx'), - 'utf8' - ) + const hostSource = readSource('src/renderer/src/components/crash-report/CrashReportDialog.tsx') const manualOpenStart = hostSource.indexOf('return window.api.ui.onOpenCrashReport(() => {') const manualOpenEnd = hostSource.indexOf(' }, [loadCrashReport])', manualOpenStart) const manualOpenBlock = hostSource.slice(manualOpenStart, manualOpenEnd) @@ -272,32 +278,34 @@ describe('renderer startup runtime routing', () => { }) it('loads dictation only when voice is enabled or a session is active', () => { - const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const source = readSource(ROOT_SURFACES_PATH) - expect(source).toContain("import('./components/dictation/DictationController').then") - expect(source).not.toContain("from './components/dictation/DictationController'") + expect(source).toContain("import('../components/dictation/DictationController').then") + expect(source).not.toContain("from '../components/dictation/DictationController'") expect(source).toContain("settings?.voice?.enabled === true || dictationState !== 'idle'") expect(source).toContain('shouldMountDictationController ?') }) it('loads the SSH passphrase dialog only when a credential request is queued', () => { - const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const source = readSource(ROOT_SURFACES_PATH) - expect(source).toContain("import('./components/settings/SshPassphraseDialog').then") - expect(source).not.toContain("from './components/settings/SshPassphraseDialog'") + expect(source).toContain("import('../components/settings/SshPassphraseDialog').then") + expect(source).not.toContain("from '../components/settings/SshPassphraseDialog'") expect(source).toContain('s.sshCredentialQueue.length > 0') expect(source).toContain('hasSshCredentialRequest ?') }) it('defers background polling until the workspace session is ready', () => { - const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') - - expect(source).toContain('useGitStatusPolling({ enabled: workspaceSessionReady })') - expect(source).toContain('') + expect(readSource(SHELL_SERVICES_PATH)).toContain( + 'useGitStatusPolling({ enabled: workspaceSessionReady })' + ) + expect(readSource(BACKGROUND_SERVICES_PATH)).toContain( + '' + ) }) it('prefetches terminal snapshot capabilities before reconnect unlocks cold activation', () => { - const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const source = readSource(STARTUP_HYDRATION_PATH) const capabilityIndex = source.indexOf( "timeRendererStartupStep('terminal-provider-snapshot-capabilities'" ) @@ -308,54 +316,61 @@ describe('renderer startup runtime routing', () => { }) it('does not load the terminal workbench on the no-workspace landing path', () => { - const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const shellSource = readSource(WORKSPACE_SHELL_PATH) + const layoutSource = readSource(CHROME_LAYOUT_PATH) - expect(source).toContain("const Terminal = lazy(() => import('./components/Terminal'))") - expect(source).not.toContain("from './components/Terminal'") - expect(source).toContain('const hasMountedTerminalWorkbenchRef = useRef(false)') - expect(source).toContain('hasMountedTerminalWorkbenchRef.current = true') - expect(source).toContain('activeWorktreeId !== null || backgroundTerminalMountRequested') - expect(source).toContain('backgroundTerminalMountRequested ||') - expect(source).toContain('hasMountedTerminalWorkbenchRef.current') - expect(source).toContain('shouldMountTerminalWorkbench ?') + expect(shellSource).toContain("const Terminal = lazy(() => import('../components/Terminal'))") + expect(shellSource).not.toContain("from '../components/Terminal'") + expect(layoutSource).toContain( + 'const canMountTerminalWorkbenchNow = activeWorktreeId !== null || backgroundTerminalMountRequested' + ) + // Why pin the latch: once the workbench has mounted it must stay mounted, so hidden + // terminal/browser/editor panes survive activeWorktreeId briefly going null. + expect(layoutSource).toContain( + 'const [hasMountedTerminalWorkbench, setHasMountedTerminalWorkbench] = useState(false)' + ) + expect(layoutSource).toContain('setHasMountedTerminalWorkbench(true)') + expect(layoutSource).toContain( + 'const shouldMountTerminalWorkbench = canMountTerminalWorkbenchNow || hasMountedTerminalWorkbench' + ) + expect(shellSource).toContain('shouldMountTerminalWorkbench ?') }) it('keeps the new-workspace composer eager because it is a critical create surface', () => { - const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') - const lazyModalSource = readFileSync( - join(process.cwd(), 'src/renderer/src/lazy-modal-mount-state.ts'), - 'utf8' - ) + const source = readSource(ROOT_SURFACES_PATH) + const lazyModalSource = readSource('src/renderer/src/lazy-modal-mount-state.ts') expect(source).toContain( - "import NewWorkspaceComposerModal from './components/NewWorkspaceComposerModal'" + "import NewWorkspaceComposerModal from '../components/NewWorkspaceComposerModal'" ) - expect(source).not.toContain("import('./components/NewWorkspaceComposerModal')") + expect(source).not.toContain("import('../components/NewWorkspaceComposerModal')") expect(source).toContain("activeModal === 'new-workspace-composer'") expect(lazyModalSource).not.toContain("'new-workspace-composer'") }) it('does not eagerly import inactive sidebar dialog flows on startup', () => { - const appSource = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') - const sidebarSource = readFileSync( - join(process.cwd(), 'src/renderer/src/components/sidebar/index.tsx'), - 'utf8' - ) + const surfacesSource = readSource(ROOT_SURFACES_PATH) + const mountSource = readSource(LAZY_MODAL_MOUNTS_PATH) + const sidebarSource = readSource('src/renderer/src/components/sidebar/index.tsx') - expect(appSource).toContain("lazy(() => import('./components/sidebar/AddRepoDialog'))") - expect(appSource).toContain("lazy(() => import('./components/sidebar/NonGitFolderDialog'))") - expect(appSource).toContain("import('./components/sidebar/AddProjectFromFolderDialog')") - expect(appSource).toContain("lazy(() => import('./components/sidebar/ProjectAddedDialog'))") - expect(appSource).toContain("activeModal === 'add-repo'") - expect(appSource).toContain("activeModal === 'confirm-non-git-folder'") - expect(appSource).toContain("activeModal === 'confirm-add-project-from-folder'") - expect(appSource).toContain("activeModal === 'project-added'") - expect(appSource).toContain('shouldMountAddRepoDialog ? (') - expect(appSource).toContain('boundaryId="modal.add-repo"') - expect(appSource).toContain('boundaryId="modal.confirm-non-git-folder"') - expect(appSource).toContain('boundaryId="modal.confirm-add-project-from-folder"') - expect(appSource).toContain('boundaryId="modal.project-added"') - expect(appSource).toContain('setTimeout(() =>') + expect(surfacesSource).toContain("lazy(() => import('../components/sidebar/AddRepoDialog'))") + expect(surfacesSource).toContain( + "lazy(() => import('../components/sidebar/NonGitFolderDialog'))" + ) + expect(surfacesSource).toContain("import('../components/sidebar/AddProjectFromFolderDialog')") + expect(surfacesSource).toContain( + "lazy(() => import('../components/sidebar/ProjectAddedDialog'))" + ) + expect(surfacesSource).toContain("activeModal === 'add-repo'") + expect(surfacesSource).toContain("activeModal === 'confirm-non-git-folder'") + expect(surfacesSource).toContain("activeModal === 'confirm-add-project-from-folder'") + expect(surfacesSource).toContain("activeModal === 'project-added'") + expect(surfacesSource).toContain('shouldMountAddRepoDialog ? (') + expect(surfacesSource).toContain('boundaryId="modal.add-repo"') + expect(surfacesSource).toContain('boundaryId="modal.confirm-non-git-folder"') + expect(surfacesSource).toContain('boundaryId="modal.confirm-add-project-from-folder"') + expect(surfacesSource).toContain('boundaryId="modal.project-added"') + expect(mountSource).toContain('setTimeout(() =>') expect(sidebarSource).toContain("lazyWithRetry(() => import('./WorktreeMetaDialog'))") expect(sidebarSource).not.toContain("from './AddRepoDialog'") expect(sidebarSource).not.toContain("React.lazy(() => import('./AddRepoDialog'))") @@ -379,10 +394,7 @@ describe('renderer startup runtime routing', () => { }) it('loads Linear agent setup implementation only after the prompt opens it', () => { - const source = readFileSync( - join(process.cwd(), 'src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx'), - 'utf8' - ) + const source = readSource('src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx') expect(source).toContain("() => import('./LinearAgentSkillSetupDialog')") expect(source).not.toContain("from './LinearAgentSkillSetupDialog'") @@ -391,10 +403,7 @@ describe('renderer startup runtime routing', () => { }) it('does not eagerly import optional status-bar segments on startup', () => { - const source = readFileSync( - join(process.cwd(), 'src/renderer/src/components/status-bar/StatusBar.tsx'), - 'utf8' - ) + const source = readSource('src/renderer/src/components/status-bar/StatusBar.tsx') expect(source).toContain("import('./ResourceUsageStatusSegment').then") expect(source).toContain("import('./PortsStatusSegment').then") @@ -407,16 +416,16 @@ describe('renderer startup runtime routing', () => { }) it('does not eagerly import the status bar shell on startup', () => { - const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const source = readSource(ROOT_SURFACES_PATH) - expect(source).toContain("import('./components/status-bar/StatusBar').then") - expect(source).not.toContain("from './components/status-bar/StatusBar'") + expect(source).toContain("import('../components/status-bar/StatusBar').then") + expect(source).not.toContain("from '../components/status-bar/StatusBar'") expect(source).toContain('statusBarVisible ? (') expect(source).toContain('h-6 min-h-[24px] shrink-0 border-t border-border') }) it('keeps activeView off the 150ms debounced UI writer hot path (#9002)', () => { - const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const source = readSource(PERSISTED_UI_WRITER_PATH) const writerStart = source.indexOf('const timer = window.setTimeout(() => {') const writerEnd = source.indexOf('}, 150)', writerStart) const writerBlock = source.slice(writerStart, writerEnd) @@ -437,7 +446,7 @@ describe('renderer startup runtime routing', () => { }) it('persists activeView through its narrow preference on every switch (#9002)', () => { - const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const source = readSource(PERSISTED_UI_WRITER_PATH) const preferenceEffect = [ '// Why (#9002): activeView has its own tiny profile preference', @@ -452,21 +461,22 @@ describe('renderer startup runtime routing', () => { }) it('arms the OSC 52 default-on notice behind a statically mounted Toaster (#10567)', () => { - const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') - // Why pin the call site: the hook is the only caller, so deleting this line silences // the migration notice on desktop with every unit suite still green. - expect(source).toContain('useOsc52ClipboardDefaultOnNotice(persistedUIReady)') + expect(readSource(SHELL_SERVICES_PATH)).toContain( + 'useOsc52ClipboardDefaultOnNotice(persistedUIReady)' + ) // Why pin the static import and the unconditional mount: sonner drops a toast enqueued // before any Toaster subscribes, and never replays it — a lazy Toaster would burn the // profile's one notice with its callbacks never firing, so it could never re-arm. - expect(source).toContain("import { Toaster } from '@/components/ui/sonner'") - expect(source).not.toContain("import('@/components/ui/sonner')") - expect(source).toContain(' { - const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const source = readSource(SESSION_PERSISTENCE_PATH) const checkpointStart = source.indexOf( 'const shutdownCheckpoint = createShutdownCheckpointGuard(' ) diff --git a/src/renderer/src/components/dashboard/agent-dashboard-performance-isolation.test.ts b/src/renderer/src/components/dashboard/agent-dashboard-performance-isolation.test.ts index 3c826c7b2b6..37a19f3fd71 100644 --- a/src/renderer/src/components/dashboard/agent-dashboard-performance-isolation.test.ts +++ b/src/renderer/src/components/dashboard/agent-dashboard-performance-isolation.test.ts @@ -10,12 +10,12 @@ function source(relativePath: string): string { describe('agent dashboard performance isolation', () => { it('keeps all dashboard feature modules out of the disabled app and sidebar path', () => { - const app = source('App.tsx') + const backgroundServices = source('app-shell/AppBackgroundServices.tsx') const sidebar = source('components/sidebar/index.tsx') const nav = source('components/sidebar/SidebarNav.tsx') - expect(app).not.toMatch(/from ['"].*DashboardPopoutBridge['"]/) - expect(app).toContain("import('./components/dashboard/DashboardPopoutBridge')") + expect(backgroundServices).not.toMatch(/from ['"].*DashboardPopoutBridge['"]/) + expect(backgroundServices).toContain("import('../components/dashboard/DashboardPopoutBridge')") expect(sidebar).not.toMatch(/from ['"].*AgentDashboard(?:Drawer|SidebarHost)['"]/) expect(sidebar).toContain("import('./AgentDashboardSidebarHost')") expect(nav).not.toContain('useAgentBucketCounts') diff --git a/src/renderer/src/startup/startup-degraded-recovery.ts b/src/renderer/src/startup/startup-degraded-recovery.ts new file mode 100644 index 00000000000..98a76f17cc9 --- /dev/null +++ b/src/renderer/src/startup/startup-degraded-recovery.ts @@ -0,0 +1,101 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '../store' +import { getStartupErrorFallbackUI } from '../lib/startup-ui-hydration' +import { + collectTerminalProviderSnapshotPtyIds, + refreshTerminalProviderSnapshotCapabilities +} from '../components/terminal/terminal-provider-snapshot-capability' + +type DegradedStartupRecoveryArgs = { + error: unknown + /** Whether ui.get() hydrated before the failure; false means defaults are safe to apply. */ + uiHydrated: boolean + /** Whether the success path already began terminal reconnect over now-partial state. */ + reconnectStarted: boolean + isCancelled: () => boolean + hydratePersistedUI: ReturnType['hydratePersistedUI'] + reconnectPersistedTerminals: (signal: AbortSignal) => Promise + abortSignal: AbortSignal +} + +// Why (issue #1158): reconnect flips workspaceSessionReady so the UI mounts, but pending* maps +// are normally drained by reconnect — clear them so a failure can't leave phantom dead PTYs. +function forceWorkspaceSessionReady(): void { + useAppStore.setState({ + workspaceSessionReady: true, + pendingReconnectWorktreeIds: [], + pendingReconnectTabByWorktree: {}, + pendingReconnectPtyIdByTabId: {} + }) +} + +/** + * Boots the app in degraded "no-save" mode after the startup hydration chain threw. + * + * Why (issue #1158): in-memory state is left untouched and hydrationSucceeded stays false + * (default-hydrating here once erased saved tabs); only the ready flags flip so the UI mounts. + */ +export async function recoverFromDegradedStartup(args: DegradedStartupRecoveryArgs): Promise { + const { + error, + uiHydrated, + reconnectStarted, + isCancelled, + hydratePersistedUI, + reconnectPersistedTerminals, + abortSignal + } = args + const stepLabel = error instanceof Error && error.message ? error.message : String(error) + console.error( + '[startup] Workspace session hydration failed; leaving disk state untouched:', + stepLabel, + error + ) + if (isCancelled()) { + return + } + // Why: degraded mode stays interactive; later repo/runtime changes must not remain gated forever. + useAppStore.setState({ startupWorktreeRefreshCompleted: true }) + // Why (issue #1158): only apply default UI if ui.get() never hydrated; otherwise defaults would clobber ui.json via the debounced writer. + const fallbackUI = getStartupErrorFallbackUI(uiHydrated) + if (fallbackUI) { + hydratePersistedUI(fallbackUI, 'startup') + } + // Why (issue #1158): sticky toast so the user knows they're in degraded "no-save" mode (hydrationSucceeded stays false); "Restart now" calls app.relaunch to recover. + toast.error(translate('auto.App.12e77cf12b', 'Session restore failed'), { + description: translate( + 'auto.App.0a9e810705', + "Changes won't be saved until restart. Your previous tabs are safe on disk." + ), + duration: Infinity, + dismissible: true, + action: { + label: translate('auto.App.caea5b51b9', 'Restart now'), + onClick: () => { + void window.api.app.relaunch() + } + } + }) + if (reconnectStarted) { + // Why (issue #1158): re-running reconnect over its partially-mutated state would double-set ptyIds and drain pending* twice — force the flag, clear pending*. + forceWorkspaceSessionReady() + return + } + try { + await window.api.app.awaitFirstWindowStartupServices() + await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() + await refreshTerminalProviderSnapshotCapabilities( + collectTerminalProviderSnapshotPtyIds(useAppStore.getState()) + ) + await reconnectPersistedTerminals(abortSignal) + await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() + } catch (reconnectErr) { + console.error('[startup] reconnectPersistedTerminals failed in error path:', reconnectErr) + // Why (issue #1158): the await may have run during StrictMode teardown; re-check cancellation so a cancelled pass 1 doesn't stomp pass 2's hydration. + if (!isCancelled()) { + // Why (issue #1158): recovery threw too; force the flag so the shell still mounts. + forceWorkspaceSessionReady() + } + } +} diff --git a/src/renderer/src/startup/startup-ssh-connection-restore.ts b/src/renderer/src/startup/startup-ssh-connection-restore.ts new file mode 100644 index 00000000000..b3ad1a42d61 --- /dev/null +++ b/src/renderer/src/startup/startup-ssh-connection-restore.ts @@ -0,0 +1,76 @@ +import type { SshConnectionState } from '../../../shared/ssh-types' +import { timeRendererStartupStep } from './startup-diagnostics' +import { reconnectSshTargetForRendererStartup } from './ssh-startup-reconnect' + +const SSH_RECONNECT_TIMEOUT_MS = 15_000 + +/** + * Re-establishes the SSH targets that were live at shutdown before terminal reconnect, so + * SSH-backed tabs route through pty.attach. Passphrase-protected and timed-out targets are + * handed back as deferred so their PTYs reattach on tab focus instead of stacking dialogs. + */ +export async function restoreSshConnectionsForStartup(args: { + connectionIds: string[] + setDeferredSshReconnectTargets: (targetIds: string[]) => void + publishSshConnectionState: (targetId: string, state: SshConnectionState) => void +}): Promise { + const { connectionIds, setDeferredSshReconnectTargets, publishSshConnectionState } = args + const allTargets = await timeRendererStartupStep('ssh-list-targets', () => + window.api.ssh.listTargets() + ) + const targetMap = new Map(allTargets.map((t) => [t.id, t])) + const targets = connectionIds.map((targetId) => ({ + targetId, + needsPassphrase: targetMap.get(targetId)?.lastRequiredPassphrase ?? false + })) + + const eagerTargets = targets.filter((t) => !t.needsPassphrase) + const deferredTargets = targets.filter((t) => t.needsPassphrase) + + if (deferredTargets.length > 0) { + setDeferredSshReconnectTargets(deferredTargets.map((t) => t.targetId)) + } + + // Why: treat timed-out eager targets as deferred so their PTYs reattach on tab focus (ssh.connect keeps running in main and likely finishes by then). + const timedOutTargets: string[] = [] + await timeRendererStartupStep( + 'ssh-reconnect', + () => + Promise.all( + eagerTargets.map(async ({ targetId }) => { + const result = await reconnectSshTargetForRendererStartup({ + targetId, + timeoutMs: SSH_RECONNECT_TIMEOUT_MS, + connect: (id) => window.api.ssh.connect({ targetId: id }), + publishState: publishSshConnectionState, + onFailure: (id, error) => { + console.warn(`SSH auto-reconnect failed for ${id}:`, error) + } + }) + if (result.timedOut) { + timedOutTargets.push(targetId) + } + }) + ), + { eagerTargets: eagerTargets.length, deferredTargets: deferredTargets.length } + ) + if (timedOutTargets.length > 0) { + setDeferredSshReconnectTargets([...deferredTargets.map((t) => t.targetId), ...timedOutTargets]) + } + + // Why: older/wrapped providers may return no state from connect; poll main once as a compatibility fallback before terminal restoration. + for (const { targetId } of eagerTargets) { + if (timedOutTargets.includes(targetId)) { + continue + } + try { + const state = await window.api.ssh.getState({ targetId }) + console.warn(`[ssh-restore] Polled state for ${targetId}: status=${state?.status}`) + if (state?.status === 'connected') { + publishSshConnectionState(targetId, state) + } + } catch { + /* best-effort */ + } + } +}