orca/src/renderer/src/App.tsx

2567 lines
112 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* eslint-disable max-lines */
import {
lazy,
Suspense,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type SetStateAction
} from 'react'
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 { 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 { 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 { 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 { 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 { 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 { 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 { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
import {
isFloatingWorkspacePanelFocused,
isFloatingWorkspacePanelShortcut,
isFloatingWorkspaceTerminalInputTarget,
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 { OPEN_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 { 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 {
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 { useRadixBodyPointerEventsRecovery } from './hooks/useRadixBodyPointerEventsRecovery'
import { registerUpdaterBeforeUnloadBypass } from './lib/updater-beforeunload'
import {
buildWorkspaceSessionPayload,
shouldPersistWorkspaceSession
} from './lib/workspace-session'
import { createSessionWriteSubscriber } from './lib/session-write-subscriber'
import {
fetchWorkspaceSessionFromHosts,
patchWorkspaceSessionByHost,
persistWorkspaceSessionByHostSync
} from './lib/workspace-session-host-persistence'
import {
getStartupErrorFallbackUI,
hydratePersistedUIAfterStartupRead
} from './lib/startup-ui-hydration'
import { shouldRenderPetOverlay } from './components/pet/pet-overlay-visibility'
import { applyDocumentTheme } from './lib/document-theme'
import { getSystemPrefersDark } from './lib/terminal-theme'
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 type { VirtualizedScrollAnchor } from './hooks/useVirtualizedScrollAnchor'
import type { RemoteWorkspacePatchResult } from '../../shared/remote-workspace-types'
import type { OnboardingState, UpdateStatus } from '../../shared/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 PhysicalModifierToken
} from '../../shared/keybindings'
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 PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog'
const isMac = navigator.userAgent.includes('Mac')
const isWindows = !isMac && navigator.userAgent.includes('Windows')
const shortcutPlatform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux'
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 a
// single dispatch path serves both. KeybindingInput-compatible (key/code +
// modifier flags) so it flows straight into keybindingMatchesAction.
type ShortcutDispatchInput = {
key?: string
code?: string
altKey?: boolean
metaKey?: boolean
ctrlKey?: boolean
shiftKey?: boolean
doubleTapModifier?: PhysicalModifierToken
target: EventTarget | null
defaultPrevented: boolean
preventDefault: () => void
}
// Why: 'hidden' titleBarStyle on Windows removes the native OS title bar,
// so we render our own minimize/maximize/close buttons. These SVG icons match
// the Fluent/Win11 style: thin 10×10 paths on a 40×30 hit area.
function WindowControls(): React.JSX.Element {
const [maximized, setMaximized] = useState(false)
useEffect(() => {
// Why: window:maximize-changed only fires on transitions, so a window
// restored to a maximized state at startup would render the wrong icon
// until the user first clicks the button. Seed from main on mount.
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 (
<div className="window-controls">
<button
className="window-controls-btn"
aria-label={translate('auto.App.bbb7f90669', 'Minimize')}
onClick={() => window.api.ui.minimize()}
>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<path d="M0 5h10v1H0z" fill="currentColor" />
</svg>
</button>
<button
className="window-controls-btn"
aria-label={
maximized
? translate('auto.App.66f0a552e5', 'Restore')
: translate('auto.App.c9d6f98459', 'Maximize')
}
onClick={() => window.api.ui.maximize()}
>
{maximized ? (
// Restore icon (two overlapping squares)
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<path d="M2 0v2H0v8h8V8h2V0H2zm6 9H1V3h7v6zM9 7H8V2H3V1h6v6z" fill="currentColor" />
</svg>
) : (
// Maximize icon (single square outline)
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<path d="M0 0v10h10V0H0zm9 9H1V1h8v8z" fill="currentColor" />
</svg>
)}
</button>
<button
className="window-controls-btn window-controls-close"
aria-label={translate('auto.App.e960d18540', 'Close')}
// Why: IPC to main so the BrowserWindow 'close' event fires, which
// sends 'window:close-requested' back to the renderer and keeps the
// terminal-running confirmation guard active. window.close() is
// unreliable in sandboxed renderers.
onClick={() => window.api.ui.requestClose()}
>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<path d="M1 0L0 1l4 4-4 4 1 1 4-4 4 4 1-1-4-4 4-4-1-1-4 4-4-4z" fill="currentColor" />
</svg>
</button>
</div>
)
}
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 SkillsPage = lazy(() => import('./components/skills/SkillsPage'))
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 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 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-loaded so the WebP asset + overlay module aren't fetched unless
// the user opts into the experimental flag.
const PetOverlay = lazy(() => import('./components/pet/PetOverlay'))
// Why: lazy so onboarding's step modules + assets aren't fetched for users
// past first-launch. The gate `shouldShowOnboarding` lives in its own tiny
// module so no eager import path pulls OnboardingFlow into the main chunk.
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'
? 'Workspace changed on another device'
: '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
}
function App(): React.JSX.Element {
const clearUnreadDockBadge = useUnreadDockBadge()
useRadixBodyPointerEventsRecovery()
useWebSessionTabsSync()
const [floatingTerminalOpen, setFloatingTerminalOpen] = useState(false)
const floatingWorkspaceTourInteractionSnapshotRef = useRef<{
wasPreviouslyInteracted?: boolean
persisted?: Promise<void>
recordFeatureInteractionForTour: boolean
} | null>(null)
// Why: Zustand actions are referentially stable, but each individual
// useAppStore(s => s.someAction) still registers a subscription that React
// must check on every store mutation. Consolidating action refs into one
// useShallow subscription means one equality check instead of many.
const actions = useAppStore(
useShallow((s) => ({
toggleSidebar: s.toggleSidebar,
fetchRepos: s.fetchRepos,
fetchProjectGroups: s.fetchProjectGroups,
fetchFolderWorkspaces: s.fetchFolderWorkspaces,
fetchAllWorktrees: s.fetchAllWorktrees,
fetchWorktreeLineage: s.fetchWorktreeLineage,
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,
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 = useAppStore((s) => s.activeWorktreeId)
const activePendingCreationId = useAppStore((s) => s.activePendingCreationId)
// Why: the creation surface owns the tab strip from the first pending frame.
// Gating it on the delayed loader flag made the tab bar swap in mid-create.
const activePendingCreationExists = useAppStore(
(s) =>
s.activePendingCreationId !== null &&
s.pendingWorktreeCreations[s.activePendingCreationId] !== undefined
)
// Why: App swaps the sidebar between workspace and landing layouts when the
// active workspace is slept/deleted. Keep virtualized scroll memory above
// that remount so the left workspace list doesn't restart at scrollTop 0.
const worktreeSidebarScrollOffsetRef = useRef(0)
const worktreeSidebarScrollAnchorRef = useRef<VirtualizedScrollAnchor>(null)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const floatingVisibleTabCount = useAppStore(selectFloatingVisibleTabCount)
const activeTabId = useAppStore((s) => s.activeTabId)
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
const canExpandPaneByTabId = useAppStore((s) => s.canExpandPaneByTabId)
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
const keybindings = useAppStore((s) => s.keybindings)
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)
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) {
hasMountedTerminalWorkbenchRef.current = true
}
// Why: skip the terminal bundle on the no-workspace landing path, but once a
// workspace has mounted, keep Terminal-owned hidden panes alive through sleep
// and shutdown transitions where activeWorktreeId can briefly become null.
const shouldMountTerminalWorkbench =
activeWorktreeId !== null || hasMountedTerminalWorkbenchRef.current
// Why: visible worktree creation owns its faux tab strip from start to finish;
// the previous workspace must stay mounted for retention without rendering
// real chrome.
const creationLayoutActive = shouldShowWorktreeCreationSurface({
activeView,
activePendingCreationId,
hasActivePendingCreation: activePendingCreationExists
})
const workspaceChromeActive =
activeView === 'terminal' && activeWorktreeId !== null && !creationLayoutActive
const terminalWorkbenchVisible =
activeView === 'terminal' && activeWorktreeId !== null && !creationLayoutActive
// Why: a closed empty floating workspace is not startup-critical. Once it owns
// tabs, keep it mounted while closed so hidden terminal/browser/editor panes
// retain their local state.
const shouldMountFloatingTerminalPanel =
floatingTerminalEnabled && (floatingTerminalOpen || floatingVisibleTabCount > 0)
// Why: the floating workspace is a transient overlay; hotkey minimize should
// return keyboard focus to the surface the user was working in before it.
const floatingTerminalReturnFocusRef = useRef<HTMLElement | null>(null)
const floatingTerminalReturnFocusFrameRef = useRef<number | null>(null)
const cancelFloatingTerminalReturnFocusFrame = useCallback((): void => {
if (floatingTerminalReturnFocusFrameRef.current === null) {
return
}
cancelAnimationFrame(floatingTerminalReturnFocusFrameRef.current)
floatingTerminalReturnFocusFrameRef.current = null
}, [])
const setAppRootNode = useCallback(
(node: HTMLDivElement | null): void => {
// Why: these best-effort App chrome cleanups share the App root lifetime.
if (!node) {
cancelFloatingTerminalReturnFocusFrame()
clearUnreadDockBadge()
}
},
[cancelFloatingTerminalReturnFocusFrame, 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<boolean>): void => {
const resolvedOpen =
typeof nextOpen === 'function' ? nextOpen(floatingTerminalOpen) : nextOpen
// Why: recordFeatureInteraction updates Zustand subscribers; doing it
// inside React's 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)
},
[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(() => {
if (!floatingTerminalEnabled) {
setFloatingTerminalOpenWithFocus(false)
}
}, [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 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
const shouldMountSetupGuideTelemetryObserver = persistedUIReady
const shouldMountUpdateCard = shouldMountUpdateCardForStatus(updateStatus)
const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth)
const markdownTocPanelWidth = useAppStore((s) => s.markdownTocPanelWidth)
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)
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<HTMLDivElement | null>(null)
const [collapsedSidebarHeaderWidth, setCollapsedSidebarHeaderWidth] = useState(0)
const [mountedLazyModalIds, setMountedLazyModalIds] = useState<Set<LazyModalId>>(() => new Set())
const [shouldMountAddRepoDialog, setShouldMountAddRepoDialog] = useState(false)
const [onboarding, setOnboarding] = useState<OnboardingState | null>(null)
const [onboardingLoaded, setOnboardingLoaded] = useState(false)
const featureTipsPromptedThisSessionRef = useRef(false)
const featureTipsSuppressedByOnboardingThisSessionRef = useRef(false)
const unmountAddRepoDialogTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [featureTipCliInstalled, setFeatureTipCliInstalled] = useState<boolean | null>(null)
const [onboardingSettingsDetour, setOnboardingSettingsDetour] = useState(false)
const shouldRenderOnboarding = onboarding !== null && shouldShowOnboarding(onboarding)
const onboardingSettingsDetourActive =
onboardingSettingsDetour && activeView === 'settings' && shouldRenderOnboarding
if (onboardingSettingsDetour && !onboardingSettingsDetourActive) {
// Why: the settings detour is valid only while Settings is onscreen; clear
// it during render so onboarding can resume without a follow-up Effect pass.
setOnboardingSettingsDetour(false)
}
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/nested work.
// Keep one closed render, then remove 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()
useAutomationDispatchEvents()
// Why: retention must run at App level so the inline per-card agents list
// always sees retained entries. If retention ran inside the sidebar-card
// subtree, "done" agents would vanish any time the user collapsed a card's
// inline agents section. The retention hooks are hosted inside
// <RetainedAgentsSyncGate /> (a leaf component that renders null) rather
// than being called inline here so its high-churn store subscriptions
// (agentStatusByPaneKey ticks at PTY event frequency)
// do not re-render the App tree on every agent status update.
// Why: git conflict-operation state also drives the worktree cards. Polling
// cannot live under RightSidebar because App unmounts that subtree when the
// sidebar is closed, which leaves stale "Rebasing"/"Merging" badges behind
// until some unrelated view remount happens to refresh them.
// Why: visible-window polling runs immediately on mount. Wait until the
// workspace session has hydrated so git status work cannot compete with the
// first window becoming usable.
useGitStatusPolling({ enabled: workspaceSessionReady })
// Why: the editor must hear external filesystem changes regardless of
// which right-sidebar panel is visible (Explorer unmounts when the user
// switches to Source Control or Checks). Wiring this at App level mirrors
// VSCode's workbench-scoped `TextFileEditorModelManager`, which reloads
// clean models from a single always-on file-change subscription instead
// of tying reloads to the Explorer UI lifecycle.
useEditorExternalWatch()
useGlobalFileDrop()
useAutoAckViewedAgent()
useEffect(() => {
return onOnboardingReopened(setOnboarding)
}, [])
useEffect(() => {
// Why: `onboarding === null` is the startup loading state. Suppress
// contextual tours until the persisted onboarding state is known so a
// first-run user cannot have a tour marked seen before onboarding appears.
const suppressTours = !onboardingLoaded || shouldShowOnboarding(onboarding)
actions.setContextualToursOnboardingVisible(suppressTours)
}, [actions, onboarding, onboardingLoaded])
useEffect(() => {
if (!persistedUIReady || !onboardingLoaded || contextualToursAutoEligible !== null) {
return
}
// Why: this rollout is for users who are still in first-run onboarding.
// Existing profiles are locally 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-download users should finish onboarding without a second
// education modal appearing later in the same first-run 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: once a tip is visible, app quit/crash should not make it reappear
// on the next launch just because the user never clicked a dismiss button.
actions.markFeatureTipsSeen([featureTipsDecision.tipId])
actions.openModal('feature-tips', { source: 'app_open', tipId: featureTipsDecision.tipId })
}, [
activeModal,
actions,
featureTipCliInstalled,
featureInteractions,
featureTipsSeenIds,
onboarding,
persistedUIReady,
settings
])
const beginOnboardingSettingsDetour = useCallback(() => {
setOnboardingSettingsDetour(true)
}, [])
// Why: sidebar open/close flips width instantaneously. useLayoutEffect
// runs synchronously after React commits the DOM but before paint, so
// dispatching SYNC_FIT_PANES_EVENT here lets the terminal reflow in the
// same frame as the width change — no "wrongly-sized terminal" transient
// and no delayed snap. The later ResizeObserver rAF and 150ms debounced
// fit both become no-ops because proposeDimensions() will match the
// already-fitted cols/rows.
useLayoutEffect(() => {
window.dispatchEvent(new CustomEvent(SYNC_FIT_PANES_EVENT))
}, [sidebarOpen, rightSidebarOpen])
// Fetch initial data + hydrate GitHub cache from disk
useEffect(() => {
let cancelled = false
// Why: AbortController must be declared outside the async block so the
// cleanup function can abort it. Under StrictMode the effect runs twice;
// without this, the first (unmounted) pass would keep spawning PTYs.
const abortController = new AbortController()
// Why (issue #1158): hydrate persisted UI immediately after ui.get()
// succeeds, before any later session step can throw. The UI writer is
// gated only on persistedUIReady, so falling back to defaults after a
// successful ui.get() would serialize those defaults back to disk.
let uiHydrated = false
// Why (issue #1158): track whether the success-path call to
// reconnectPersistedTerminals started so the catch path doesn't run it a
// second time. Reconnect mutates store state via per-tab set() blocks
// inside its loops (populating tabsByWorktree / ptyIdsByTabId); re-entering
// it on partially-mutated state would double-set ptyIds and drain pending*
// maps twice. If the success-path call started and threw mid-loop, those
// per-tab set() blocks may have populated tabsByWorktree / ptyIdsByTabId
// for some tabs but did NOT reach the tail set() that flips
// workspaceSessionReady — so we still need to force the flag true so the
// UI mounts.
let reconnectStarted = false
void (async () => {
try {
// Why: repo/worktree hydration routes through settings.activeRuntimeEnvironmentId.
// Load settings first so a persisted remote runtime does not boot against
// the local filesystem and then hydrate stale local workspace state.
await actions.fetchSettings()
await actions.fetchRepos()
await actions.fetchProjectGroups()
await actions.fetchFolderWorkspaces()
await actions.fetchAllWorktrees()
await actions.fetchWorktreeLineage()
const persistedUI = await window.api.ui.get()
uiHydrated = hydratePersistedUIAfterStartupRead({
persistedUI,
cancelled,
hydratePersistedUI: actions.hydratePersistedUI
})
// Why: runtime-owned worktree slices live in per-host partitions.
// Repos were fetched above, so the known runtime hosts are derivable
// here; merge their slices into the unified session the hydrators
// expect. An unreadable host partition is skipped (fail-soft).
const session = await fetchWorkspaceSessionFromHosts(
window.api.session,
useAppStore.getState().repos
)
await actions.fetchKeybindings()
if (!cancelled) {
actions.hydrateWorkspaceSession(session)
actions.hydrateTabsSession(session)
actions.hydrateEditorSession(session)
actions.hydrateBrowserSession(session)
// Why: prune lastVisitedAtByWorktreeId entries whose worktrees
// no longer exist. Must run AFTER hydration — before this point,
// async repo loads may not have populated worktreesByRepo yet and
// pruning would delete timestamps for worktrees that are about to
// appear. Seed the restored active worktree's timestamp if missing
// so users upgrading from a pre-feature build don't see the active
// worktree sink in the empty-query list.
// See docs/cmd-j-empty-query-ordering.md.
actions.pruneLastVisitedTimestamps()
actions.seedActiveWorktreeLastVisitedIfMissing()
await actions.fetchBrowserSessionProfiles()
const onboardingState = await window.api.onboarding.get()
if (!cancelled) {
setOnboarding(onboardingState)
setOnboardingLoaded(true)
}
// Why: SSH connections must be re-established BEFORE terminal
// reconnect so that reconnectPersistedTerminals can route SSH-backed
// tabs through pty.attach on the relay. Passphrase-protected targets
// are deferred to tab focus to avoid stacking credential dialogs at
// startup before the user has context.
const connectionIds = session.activeConnectionIdsAtShutdown ?? []
if (connectionIds.length > 0) {
try {
const SSH_RECONNECT_TIMEOUT_MS = 15_000
const allTargets = await 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: track which eager targets timed out so we can treat them
// as deferred — the underlying ssh.connect() keeps running in the
// main process, but reconnectPersistedTerminals won't see them as
// connected. Adding them to the deferred list ensures PTYs get
// reattached when the user focuses the tab (by which time the
// slow connect will likely have succeeded).
const timedOutTargets: string[] = []
await Promise.allSettled(
eagerTargets.map(({ targetId }) =>
Promise.race([
window.api.ssh.connect({ targetId }),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('SSH reconnect timeout')),
SSH_RECONNECT_TIMEOUT_MS
)
)
]).catch((err) => {
const isTimeout =
err instanceof Error && err.message === 'SSH reconnect timeout'
if (isTimeout) {
timedOutTargets.push(targetId)
}
console.warn(`SSH auto-reconnect failed for ${targetId}:`, err)
})
)
)
if (timedOutTargets.length > 0) {
actions.setDeferredSshReconnectTargets([
...deferredTargets.map((t) => t.targetId),
...timedOutTargets
])
}
// Why: ssh.connect() resolves before the ssh:state-changed IPC
// event updates sshConnectionStates in the store. Without this,
// reconnectPersistedTerminals reads stale state and misclassifies
// successfully connected targets as disconnected, stranding their
// persisted PTYs. Polling getState ensures the store is current.
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)
}
}
// Why: main overlaps daemon/hook startup with renderer hydration for
// first paint, but restored terminals still need those services ready
// before they mount and spawn/reconnect PTYs.
await window.api.app.awaitFirstWindowStartupServices()
reconnectStarted = true
await actions.reconnectPersistedTerminals(abortController.signal)
syncZoomCSSVar()
// Why (issue #1158): unlock the debounced session writer only after
// hydration AND all dependent startup steps (SSH reconnect, terminal
// reconnect) completed without throwing. If this flag flipped earlier
// and a later step threw, the catch path's reconnectPersistedTerminals
// would flip workspaceSessionReady=true with the gate already open,
// and the writer would serialize a partially-mutated store back to
// disk — the exact data-loss mode this PR fixes.
actions.setHydrationSucceeded(true)
}
} catch (error) {
// Why (issue #1158): previously this catch called hydrateWorkspaceSession
// with empty defaults, which overwrote the in-memory tab map. The
// debounced session writer then serialized that empty state back to
// orca-data.json, silently erasing the user's saved tabs. The fix is
// to leave in-memory state untouched and keep hydrationSucceeded
// false so the writer stays gated. We still ensure persistedUIReady and
// workspaceSessionReady flip so the UI can mount without a session.
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 (issue #1158): only hydrate UI with defaults if ui.get() never
// produced persisted data. If the real UI hydrate already ran and a
// later session step threw, defaults would flow through the debounced
// UI writer and clobber ui.json (sidebar width, sort, filters, etc.).
const fallbackUI = getStartupErrorFallbackUI(uiHydrated)
if (fallbackUI) {
actions.hydratePersistedUI(fallbackUI)
}
// Why (issue #1158): surface a sticky, dismissible toast so the
// user knows they're in degraded "no-save" mode. Without this, every
// new tab/file/browse becomes silently ephemeral — `hydrationSucceeded`
// stays false for the rest of the process and the session writer is
// a no-op. The "Restart now" action calls app.relaunch (defined in
// src/main/ipc/app.ts) so the user can recover with one click instead
// of having to find a quit/relaunch path themselves.
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: reconnectPersistedTerminals flips workspaceSessionReady so the
// UI mounts; auto-tab-creation becomes unblocked. hydrationSucceeded
// is intentionally NOT set — the session writer must stay a no-op
// until the user gets a clean restart, so we don't overwrite the
// on-disk file we failed to load.
if (!reconnectStarted) {
try {
await window.api.app.awaitFirstWindowStartupServices()
await actions.reconnectPersistedTerminals(abortController.signal)
} catch (reconnectErr) {
console.error(
'[startup] reconnectPersistedTerminals failed in error path:',
reconnectErr
)
// Why (issue #1158): re-check !cancelled before mutating store
// state. The await above may have run while the effect was being
// torn down (StrictMode pass 1 cleanup) — in that case the
// second pass owns hydration and we must not stomp its work
// from a cancelled run.
if (!cancelled) {
// Why (issue #1158): this is already the recovery path from a
// failed hydration. If the recovery itself throws, the async IIFE
// rejects as an unhandled promise and workspaceSessionReady never
// flips — leaving the user staring at a blank window. Forcing the
// flag true lets the app shell mount with an empty session, which
// is strictly better than a non-functional UI.
//
// Also clear pendingReconnect* maps because reconnectPersistedTerminals
// normally drains them as part of its post-conditions
// (see terminals.ts post-loop cleanup). Bypassing that drain by
// flipping only the flag would leave stale reconnect data in
// memory — any later reader of pending* maps could trigger
// phantom reconnect attempts on PTYs that no longer exist.
useAppStore.setState({
workspaceSessionReady: true,
pendingReconnectWorktreeIds: [],
pendingReconnectTabByWorktree: {},
pendingReconnectPtyIdByTabId: {}
})
}
}
} else {
// Why (issue #1158): the success-path call to
// reconnectPersistedTerminals already started; its per-tab set()
// blocks may have populated tabsByWorktree / ptyIdsByTabId for
// some tabs but did NOT reach the tail set() that flips
// workspaceSessionReady (that runs after the loop completes).
// Don't re-run reconnect over partially-mutated state — doing so
// would double-set ptyIds and drain pending* maps twice. Force
// the flag true so the UI mounts. The same pending* clear applies
// here for the same reason as above.
useAppStore.setState({
workspaceSessionReady: true,
pendingReconnectWorktreeIds: [],
pendingReconnectTabByWorktree: {},
pendingReconnectPtyIdByTabId: {}
})
}
}
}
void actions.initGitHubCache()
})()
return () => {
cancelled = true
abortController.abort()
}
}, [actions])
useEffect(() => {
setRuntimeGraphStoreStateGetter(useAppStore.getState)
return () => {
setRuntimeGraphStoreStateGetter(null)
}
}, [])
useEffect(() => {
let previousKey = getRuntimeMobileSessionSyncKey(useAppStore.getState())
return useAppStore.subscribe((state, previousState) => {
const systemPrefersDark = getSystemPrefersDark()
// Why: skip the key build entirely when every input field is unchanged
// by reference. Mirrors every field used by
// getRuntimeMobileSessionSyncKey so this gate covers every "could the
// key have changed?" case.
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 never drives JSX — it only writes to disk.
// Using a Zustand subscribe() outside React removes ~15 subscriptions from
// App's render cycle, eliminating re-renders on every tab/file/browser change.
useEffect(() => {
return createSessionWriteSubscriber({
store: useAppStore,
shouldSchedulePersist: () => !isRemoteWorkspaceSnapshotApplyInProgress(),
persist: ({ patch }) => {
const state = useAppStore.getState()
// Why: route each runtime host's worktree-scoped slice to its own
// partition; the returned promise is 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 to disk.
// Runs synchronously in beforeunload: capture → Zustand set → sendSync → flush.
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.
let shutdownBuffersCaptured = false
const captureAndFlush = (): void => {
if (shutdownBuffersCaptured) {
return
}
if (!shouldPersistWorkspaceSession(useAppStore.getState())) {
return
}
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()
// 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()
persistWorkspaceSessionByHostSync(
window.api.session,
buildWorkspaceSessionPayload(freshState),
freshState
)
shutdownBuffersCaptured = true
}
window.addEventListener('beforeunload', captureAndFlush)
return () => window.removeEventListener('beforeunload', captureAndFlush)
}, [])
// Own the single window-close-request subscription at the always-mounted App
// root. Why: the rich confirmation flow lives in Terminal, which is not
// mounted on the no-workspace landing page (and is lazy-loaded elsewhere), so
// subscribing there left File → Exit / Ctrl+Q with no listener and the window
// never closed (#5144). dispatchWindowCloseRequest delegates to Terminal's
// handler when present, else confirms the close directly.
useEffect(() => {
return window.api.ui.onWindowCloseRequested(dispatchWindowCloseRequest)
}, [])
// Why there is no periodic scrollback save: PR #461 added a 3-minute
// setInterval that re-serialized every mounted TerminalPane's scrollback
// so a crash wouldn't lose in-session output. With many panes of
// accumulated output, each tick blocked the renderer main thread for
// several seconds (serialize is synchronous and does a binary search on
// >512KB buffers), causing visible input lag across the whole app.
// The durable replacement is the out-of-process terminal daemon
// (PR #729), which preserves buffers across renderer crashes with no
// main-thread work. Non-daemon users lose in-session scrollback on an
// unexpected exit — an acceptable tradeoff vs. periodic UI stalls, and
// in line with how most terminal apps behave.
useEffect(() => {
if (!persistedUIReady) {
return
}
const timer = window.setTimeout(() => {
void window.api.ui.set({
sidebarWidth,
rightSidebarOpen,
rightSidebarTab,
rightSidebarExplorerView,
rightSidebarWidth,
markdownTocPanelWidth,
groupBy,
sortBy,
projectOrderBy,
showActiveOnly: false,
hideSleepingWorkspaces: !showSleepingWorkspaces,
showSleepingWorkspaces,
hideDefaultBranchWorkspace,
showDotfilesByWorktree,
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
})
}, 150)
return () => window.clearTimeout(timer)
}, [
persistedUIReady,
sidebarWidth,
rightSidebarOpen,
rightSidebarTab,
rightSidebarExplorerView,
rightSidebarWidth,
markdownTocPanelWidth,
groupBy,
sortBy,
projectOrderBy,
showSleepingWorkspaces,
hideDefaultBranchWorkspace,
showDotfilesByWorktree,
filterRepoIds,
acknowledgedAgentsByPaneKey
])
// 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
} else {
// system
const mq = window.matchMedia('(prefers-color-scheme: dark)')
applyDocumentTheme('system')
const handler = (): void => {
applyDocumentTheme('system')
// Why: system theme changes do not 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])
const tabs = activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []
const hasTabBar = tabs.length >= 2
const effectiveActiveTabId = activeTabId ?? tabs[0]?.id ?? null
const activeTabCanExpand = effectiveActiveTabId
? (canExpandPaneByTabId[effectiveActiveTabId] ?? false)
: false
const effectiveActiveTabExpanded = effectiveActiveTabId
? (expandedPaneByTabId[effectiveActiveTabId] ?? false)
: false
const showTitlebarExpandButton = workspaceChromeActive && !hasTabBar && effectiveActiveTabExpanded
// Why: Activity and Space are full-page navigation surfaces — same
// treatment as Settings — so the worktree sidebar is removed for those views.
const showSidebar =
activeView !== 'settings' &&
activeView !== 'activity' &&
activeView !== 'space' &&
activeView !== 'skills'
// Why: Tasks/Landing keep the full titlebar only when the sidebar is
// collapsed; with it open, mirror workspace view so titlebar-left sits flush
// above nav. Creation layout suppresses the full-width titlebar.
const stackedSidebarOpen =
!workspaceChromeActive && !creationLayoutActive && showSidebar && sidebarOpen
// Why: visible creation keeps only the top-left window chrome; workspace tabs
// and right-sidebar chrome remain gated by workspaceChromeActive.
const leftTitlebarChromeLayout = resolveLeftTitlebarChromeLayout({
workspaceChromeActive,
stackedSidebarOpen,
creationLayoutActive,
sidebarOpen
})
// Why: suppress right sidebar controls on full-page navigation surfaces
// since those surfaces intentionally own the full content area.
const showRightSidebarControls = !creationLayoutActive && canShowRightSidebarForView(activeView)
const handleToggleExpand = (): void => {
if (!effectiveActiveTabId) {
return
}
window.dispatchEvent(
new CustomEvent(TOGGLE_TERMINAL_PANE_EXPAND_EVENT, {
detail: { tabId: effectiveActiveTabId }
})
)
}
useEffect(() => {
const doubleTapDetector = new ModifierDoubleTapDetector()
const dispatchShortcutInput = (input: ShortcutDispatchInput): void => {
// Why: child-component handlers (e.g. terminal search Cmd+G / Cmd+Shift+G)
// register on the same window capture phase and fire first. If they already
// called preventDefault, this handler must not also act on the event —
// otherwise both actions execute (e.g. search navigation AND sidebar open).
if (input.defaultPrevented) {
return
}
// Why: the Settings recorder intentionally captures existing app
// 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 app-level shortcuts are also intercepted via
// before-input-event in createMainWindow.ts so they still work when a
// browser guest has focus. The renderer keeps matching handlers for
// local-focus cases and to preserve the same guards in one place.
const matchShortcut = (actionId: KeybindingActionId): boolean =>
keybindingMatchesAction(actionId, input, shortcutPlatform, keybindings, {
context,
terminalShortcutPolicy: settings?.terminalShortcutPolicy
})
const notifyTerminalCapture = (actionId: KeybindingActionId): void => {
if (
context !== 'terminal' ||
(settings?.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) {
// Why: when focus is inside the file explorer and a folder is selected,
// Cmd/Ctrl+Shift+F means "Find in Folder" — seed the include pattern
// with that folder instead of treating the chord as a text-search seed.
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
}
}
// Why: an empty floating workspace has no tab to close; Cmd/Ctrl+W
// should hide that transient overlay before underlying app surfaces act.
if (
keybindingMatchesAction('tab.close', input, shortcutPlatform, keybindings, {
context: 'app'
}) &&
shouldMinimizeFloatingWorkspacePanelOnCloseShortcut({
floatingTerminalOpen,
floatingVisibleTabCount
})
) {
input.preventDefault()
setFloatingTerminalOpenWithFocus(false)
return
}
// Why: keep this guard. TipTap's Cmd+B bold binding depends on the
// window-level handler *not* toggling the sidebar when focus lives in an
// editable surface. The main-process before-input-event already carves out
// Cmd+B for the markdown editor (see createMainWindow.ts +
// docs/markdown-cmd-b-bold-design.md), but this renderer-side fallback
// still covers the blur→press IPC race and any non-carved editable surface.
if (isEditableTarget(input.target)) {
return
}
// Why: xterm's helper textarea is intentionally not a generic editable
// target, but floating-terminal SSH/tmux control chords must still reach
// the terminal instead of app-level chrome shortcuts.
if (isFloatingWorkspaceTerminalInputTarget(input.target)) {
return
}
// Cmd/Ctrl+Alt+Arrow — worktree history back/forward. This stays before
// right-sidebar shortcuts because it is navigation, not sidebar reveal.
if (matchShortcut('worktree.history.back') || matchShortcut('worktree.history.forward')) {
// Why: Back/Forward traverse mixed worktree + page visits, so the
// shortcut is active wherever the titlebar button cluster is (terminal
// or stack-backed pages). Still suppressed in Settings.
if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) {
return
}
input.preventDefault()
const store = useAppStore.getState()
if (matchShortcut('worktree.history.back')) {
store.goBackWorktree()
} else {
store.goForwardWorktree()
}
return
}
// Why: only short-circuit chords the floating panel's own keydown
// handler claims (Cmd/Ctrl+T, Cmd/Ctrl+W, Cmd/Ctrl+Shift+B/M). Other
// app-level mod shortcuts (B, L, Shift+E/F/G) have no panel-level
// counterpart, so suppressing them here would silently no-op when
// focus lives inside the floating panel.
const floatingWorkspaceFocused = isFloatingWorkspacePanelFocused()
if (floatingWorkspaceFocused) {
if (
isFloatingWorkspacePanelShortcut(input, shortcutPlatform, null, keybindings, {
context,
terminalShortcutPolicy: settings?.terminalShortcutPolicy
})
) {
return
}
}
// Cmd/Ctrl+B — toggle left sidebar
if (matchShortcut('sidebar.left.toggle')) {
input.preventDefault()
notifyTerminalCapture('sidebar.left.toggle')
actions.toggleSidebar()
return
}
// Why: rename the active terminal tab. Cmd+R is free in the app/terminal
// focus zone because the browser pane owns its own Cmd+R reload and that
// focus never reaches this renderer-window handler. Only terminal tabs
// have an inline title editor, so other active tab types fall through.
if (workspaceChromeActive && !floatingWorkspaceFocused && matchShortcut('tab.rename')) {
const store = useAppStore.getState()
if (store.activeTabType === 'terminal' && store.activeTabId) {
input.preventDefault()
notifyTerminalCapture('tab.rename')
store.setRenamingTabId(store.activeTabId)
return
}
}
// Why: open the active worktree's inline title editor. Open/reveal it
// first so the card is mounted and visible even when sidebar filters or
// collapse state would otherwise hide it.
if (
workspaceChromeActive &&
!floatingWorkspaceFocused &&
matchShortcut('workspace.rename') &&
activeWorktreeId
) {
input.preventDefault()
notifyTerminalCapture('workspace.rename')
const store = useAppStore.getState()
store.setSidebarOpen(true)
requestScrollToCurrentWorkspaceRevealAndRename()
return
}
if (matchShortcut('workspace.openBoard') && activeView !== 'settings') {
input.preventDefault()
notifyTerminalCapture('workspace.openBoard')
const store = useAppStore.getState()
store.setSidebarOpen(true)
window.dispatchEvent(new CustomEvent(OPEN_WORKSPACE_BOARD_EVENT))
return
}
// Why: Cmd/Ctrl+N is handled via the main-process before-input-event
// allowlist (see window-shortcut-policy.ts / useIpcEvents.ts) so it works
// globally — including when focus lives inside the markdown rich editor
// (contentEditable) or a browser guest webContents, both of which bypass
// this renderer-side window keydown listener.
// Why: full-page navigation surfaces should not reveal the right sidebar;
// they are designed as distraction-free content areas.
if (matchShortcut('view.tasks') && activeView !== 'settings') {
const store = useAppStore.getState()
if (store.repos.some((repo) => isGitRepoKind(repo))) {
input.preventDefault()
notifyTerminalCapture('view.tasks')
store.openTaskPage()
}
return
}
if (!canRevealRightSidebar) {
return
}
// Cmd/Ctrl+L — toggle right sidebar
if (matchShortcut('sidebar.right.toggle')) {
input.preventDefault()
notifyTerminalCapture('sidebar.right.toggle')
actions.toggleRightSidebar()
return
}
// Cmd/Ctrl+Shift+E — toggle right sidebar / explorer tab
if (matchShortcut('sidebar.explorer.toggle')) {
input.preventDefault()
notifyTerminalCapture('sidebar.explorer.toggle')
actions.showRightSidebarFiles()
return
}
// Cmd/Ctrl+Shift+F — toggle right sidebar / search tab
if (matchShortcut('sidebar.search.toggle')) {
input.preventDefault()
notifyTerminalCapture('sidebar.search.toggle')
openSearchSidebar(null)
return
}
// Cmd/Ctrl+Shift+G — toggle right sidebar / source control tab.
// Skip when terminal search is open — Cmd+Shift+G means "find previous"
// in that context (handled by keyboard-handlers.ts). Both listeners share
// the window capture phase and registration order can vary with React
// effect re-runs, so a DOM check is the reliable coordination mechanism.
if (matchShortcut('sidebar.sourceControl.toggle')) {
if (document.querySelector('[data-terminal-search-root]')) {
return
}
input.preventDefault()
notifyTerminalCapture('sidebar.sourceControl.toggle')
actions.setRightSidebarTab('source-control')
actions.setRightSidebarOpen(true)
return
}
if (matchShortcut('sidebar.checks.toggle')) {
input.preventDefault()
notifyTerminalCapture('sidebar.checks.toggle')
actions.setRightSidebarTab('checks')
actions.setRightSidebarOpen(true)
return
}
// Cmd+Shift+I — toggle right sidebar / ports tab (macOS only).
// Why: Ctrl+Shift+I is the built-in DevTools accelerator on Windows/Linux;
// intercepting it would break an essential developer tool.
if (matchShortcut('sidebar.ports.toggle')) {
input.preventDefault()
notifyTerminalCapture('sidebar.ports.toggle')
actions.setRightSidebarTab('ports')
actions.setRightSidebarOpen(true)
}
}
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 () => {
window.removeEventListener('keydown', onKeyDown, { capture: true })
window.removeEventListener('keyup', onKeyUp, { capture: true })
window.removeEventListener('blur', onBlur)
}
}, [
activeView,
activeWorktreeId,
actions,
floatingTerminalOpen,
floatingVisibleTabCount,
keybindings,
settings?.terminalShortcutPolicy,
setFloatingTerminalOpenWithFocus,
workspaceChromeActive,
creationLayoutActive
])
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 these modals only after first use, then keep them mounted
// so repeat opens preserve their local state and avoid re-fetch flashes.
setMountedLazyModalIds(new Set(resolvedMountedLazyModalIds))
}
// Why: extracted so both the full-width titlebar (settings/landing) and
// the sidebar-width left header (workspace view) can share the same
// controls without duplicating the agent badge popover.
const titlebarLeftControls = (
// Why: measure the ENTIRE row (traffic-light pad + sidebar toggle + agent
// badge + back/forward group) so the sidebar-collapse spacer in
// TabGroupPanel reserves enough width to clear the full floating
// `titlebar-left`. Measuring only the inner control cluster left the
// back/forward arrows hanging over the first tab when the sidebar was
// collapsed (Cmd+B), producing a half-occluded, non-scrollable tab strip.
// Why: collapsed workspace mode floats inside a w-0 sidebar wrapper; w-max
// prevents Windows Chromium from shrinking the app name down to one glyph.
<div
ref={titlebarLeftControlsRef}
className={`flex h-full shrink-0 items-center${
leftTitlebarChromeLayout.isFloating ? ' w-max' : ' w-full'
}`}
>
<div className="flex h-full items-center">
{isMac && !isFullScreen ? (
<div className="titlebar-traffic-light-pad" />
) : isWindows ? (
/* Why: on Windows the native title bar is hidden, so we render the
Orca logo as a non-interactive identity anchor and a ··· button
that pops up the application menu (the same menu revealed by Alt
on the default autoHideMenuBar). */
<>
<img src={logo} alt="" aria-hidden className="titlebar-logo" />
<Tooltip>
<TooltipTrigger asChild>
<button
className="titlebar-icon-button"
aria-label={translate('auto.App.8b0b8eb54f', 'Application menu')}
onClick={() => window.api.ui.popupMenu()}
>
<MoreHorizontal size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.App.8b0b8eb54f', 'Application menu')}
</TooltipContent>
</Tooltip>
</>
) : (
<div className="pl-2" />
)}
{showSidebar && !isWindows && (
<>
{settings?.showTitlebarAppName !== false && (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className="titlebar-app-name"
aria-label={translate('auto.App.5096cbbc86', 'Orca')}
>
<span className="titlebar-app-name-main">
{translate('auto.App.5096cbbc86', 'Orca')}
</span>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onSelect={() => {
void actions.updateSettings({ showTitlebarAppName: false })
}}
>
{translate('auto.App.e81217c1b7', 'Hide App Name')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
</>
)}
{showSidebar && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="sidebar-toggle"
onClick={actions.toggleSidebar}
aria-label={translate('auto.App.e4b9e7dff7', 'Toggle sidebar')}
>
<PanelLeft size={16} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.App.ce37cf5279', 'Toggle sidebar ({{value0}})', {
value0: leftSidebarShortcutLabel
})}
</TooltipContent>
</Tooltip>
)}
</div>
{/* Why: Back/Forward traverse mixed worktree + page history, so the
cluster is shown wherever the history shortcut is live. Hidden in
Settings and non-stack page views. */}
{shouldShowWorktreeHistoryControls(activeView) && (
// Why: when the workspace sidebar is collapsed, this header shrink-wraps
// and ml-auto has no spare width; keep a fixed gutter before Back.
<div className="ml-auto mr-3 flex items-center pl-2">
<Tooltip>
<TooltipTrigger asChild>
<button
className="sidebar-toggle sidebar-toggle-compact"
onClick={() => useAppStore.getState().goBackWorktree()}
disabled={!canGoBackWorktree}
aria-label={translate('auto.App.064bd07810', 'Go back')}
>
<ArrowLeft size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.App.fe21e8f6f5', 'Go back ({{value0}})', {
value0: historyBackShortcutLabel
})}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
className="sidebar-toggle sidebar-toggle-compact"
onClick={() => useAppStore.getState().goForwardWorktree()}
disabled={!canGoForwardWorktree}
aria-label={translate('auto.App.cf9099fe98', 'Go forward')}
>
<ArrowRight size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.App.f7aa73e785', 'Go forward ({{value0}})', {
value0: historyForwardShortcutLabel
})}
</TooltipContent>
</Tooltip>
</div>
)}
</div>
)
const rightSidebarToggle = showRightSidebarControls ? (
<Tooltip>
<TooltipTrigger asChild>
<button
className="sidebar-toggle mr-2"
onClick={actions.toggleRightSidebar}
aria-label={translate('auto.App.9e0b441a91', 'Toggle right sidebar')}
>
<PanelRight size={16} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.App.c184e056de', 'Toggle right sidebar ({{value0}})', {
value0: rightSidebarShortcutLabel
})}
</TooltipContent>
</Tooltip>
) : null
const titlebarMainStrip = (
<>
{activeView === 'activity' ? (
<ActivityTitlebarControls />
) : creationLayoutActive ? null : (
<div
id="titlebar-tabs"
className={`flex flex-1 min-w-0 self-stretch${!workspaceChromeActive ? ' invisible pointer-events-none' : ''}`}
/>
)}
{showTitlebarExpandButton && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="titlebar-icon-button"
onClick={handleToggleExpand}
aria-label={translate('auto.App.c1cf0b0e4a', 'Collapse pane')}
disabled={!activeTabCanExpand}
>
<Minimize2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.App.c1cf0b0e4a', 'Collapse pane')}
</TooltipContent>
</Tooltip>
)}
{/* Why: when the right sidebar is open, its own header renders
an identical close button — hide this copy so only one is
visible at a time. */}
{!rightSidebarOpen && rightSidebarToggle}
{/* Why: reserve space so content is not obscured by the
fixed-position window-controls overlay on Windows. */}
{isWindows && <div className="window-controls-titlebar-spacer" />}
</>
)
return (
<div
ref={setAppRootNode}
className="flex flex-col h-dvh w-screen overflow-hidden"
style={
{
'--collapsed-sidebar-header-width': `${collapsedSidebarHeaderWidth}px`,
// Why: consumed by anything that needs to avoid the fixed-position
// window-controls overlay on Windows (floating sidebar toggle, right
// sidebar header, etc.) without hardcoding 138px in multiple places.
'--window-controls-width': isWindows ? '138px' : '0px',
// Why: consumed by the side-position activity bar to push icons below
// the fixed-position window-controls overlay on Windows.
'--window-controls-height': isWindows ? '36px' : '0px'
} as React.CSSProperties
}
>
<TooltipProvider delayDuration={400}>
<ConfirmationDialogProvider>
<LinkRoutingPreferenceDialogProvider>
<WorkspacePortScanner enabled={workspaceSessionReady} />
{/* Why: leaf-mounted retention sync keeps agent-status retention
subscriptions from re-rendering the App tree. */}
<RetainedAgentsSyncGate />
<AgentHibernationGate />
{/* Why: workspace activation is a hot path; including activeWorktreeId
in reset keys remounts whole surfaces during wake. */}
<RecoverableRenderErrorBoundary
boundaryId="app.workspace-shell"
surface="workspace-shell"
resetKey={activeView}
title={translate('auto.App.df1d56bf87', 'The workspace shell hit an error.')}
description={translate(
'auto.App.8504ddf267',
'The app is still running. Retry the shell or use the menu to report the crash details.'
)}
>
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
{/* Why: the non-workspace titlebar lives inside this left+center
wrapper so it does not span over the right-sidebar column —
when the right sidebar is open, its own header anchors at the
top alongside the titlebar instead of being pushed below it. */}
<div className="flex flex-col flex-1 min-w-0 min-h-0">
{/* Why: in workspace view (split groups always enabled), the
full-width titlebar is removed so tab groups + terminal extend
to the top of the window. Left titlebar controls move to a
header above the sidebar. Settings, landing, and the tasks
page keep the titlebar. */}
{!leftTitlebarChromeLayout.shouldMount ? (
<div className="titlebar">
<div className="flex items-center shrink-0 mr-2">{titlebarLeftControls}</div>
{titlebarMainStrip}
</div>
) : null}
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
{showSidebar ? (
leftTitlebarChromeLayout.shouldMount ? (
/* Why: left column wraps the sidebar with a titlebar-height
header above it. The header holds the same controls
(traffic lights, sidebar toggle, "Orca" title, agent badge)
that the full-width titlebar held while the center and right
columns keep their own top strips at the same 36px height.
When the sidebar is collapsed, take this header out of flex
layout so the terminal/editor reclaim the left edge instead of
leaving behind a content-width blank strip. */
<div
className={`flex min-h-0 flex-col shrink-0${sidebarOpen ? '' : ' relative w-0 overflow-visible'}`}
>
<div
// Why: when the sidebar is collapsed, titlebar-left floats
// absolutely on top of the center column's own `border-l`
// (see TabGroupSplitLayout), occluding that seam. Add a
// `border-r` in the floating state so the vertical line
// between the traffic-light/nav cluster and the tab strip
// stays visible in both states. w-max keeps the floating
// header sized to its own controls instead of the w-0
// sidebar wrapper.
className={`titlebar-left${
leftTitlebarChromeLayout.isFloating
? ' titlebar-left-floating absolute top-0 left-0 z-10 w-max border-r border-border'
: ''
}`}
style={{
// Why: custom sidebar appearances are scoped to the sidebar
// root, so mirror those variables onto the open header that
// visually belongs to the same left-column panel.
...(sidebarOpen ? leftSidebarStyle : undefined),
// Why: the Sidebar resize hook updates the sidebar DOM width
// directly during drag and only persists to Zustand on
// mouseup. In workspace view, size this header from the
// wrapper's live width so it tracks those in-flight resizes
// instead of leaving a stale-width gap until the drag ends.
width: sidebarOpen ? '100%' : undefined
}}
>
{titlebarLeftControls}
</div>
<div className="flex min-h-0 flex-1">
{/* Why: the workspace-view wrapper adds a fixed 36px header
above the sidebar. Without a flex-1/min-h-0 slot here,
the sidebar falls back to its content height, so the
worktree list loses its scroll viewport and the fixed
bottom toolbar (including Add Project) gets pushed offscreen. */}
<RecoverableRenderErrorBoundary
boundaryId="sidebar.worktrees"
surface="sidebar"
resetKey={activeView}
title={translate(
'auto.App.1468601e7b',
'The workspace list hit an error.'
)}
description={translate(
'auto.App.bdc71dddc9',
'The active workspace remains open. Retry the list or switch views.'
)}
>
<Sidebar
worktreeScrollOffsetRef={worktreeSidebarScrollOffsetRef}
worktreeScrollAnchorRef={worktreeSidebarScrollAnchorRef}
/>
</RecoverableRenderErrorBoundary>
</div>
</div>
) : (
<RecoverableRenderErrorBoundary
boundaryId="sidebar.worktrees"
surface="sidebar"
resetKey={activeView}
title={translate(
'auto.App.1468601e7b',
'The workspace list hit an error.'
)}
description={translate(
'auto.App.cba0fafda5',
'The active page remains open. Retry the list or switch views.'
)}
>
<Sidebar
worktreeScrollOffsetRef={worktreeSidebarScrollOffsetRef}
worktreeScrollAnchorRef={worktreeSidebarScrollAnchorRef}
/>
</RecoverableRenderErrorBoundary>
)
) : null}
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
{stackedSidebarOpen ? (
<div className="titlebar">{titlebarMainStrip}</div>
) : null}
<div className="relative flex flex-1 min-w-0 min-h-0 overflow-hidden">
{/* Why: right sidebar toggle floats at the top-right of the center
column so it's always accessible whether the right sidebar is
open or closed. Match the RightSidebar header's 36px height and
top-0 anchor so the icon's vertical center is identical between
open and closed states — otherwise toggling makes the icon jump
a few pixels, which reads as layout jitter. */}
{workspaceChromeActive && !rightSidebarOpen && (
<div
className="absolute top-0 z-10 flex items-center h-[36px]"
style={
{
// Why: right: var(--window-controls-width) is the single
// mechanism that keeps the toggle clear of the
// fixed-position window-controls overlay on Windows (138px)
// and sits at the right edge on non-Windows (0px). No
// internal spacer needed — adding one would push the button
// a further 138px to the left and cover the pane-actions
// Ellipsis button with an un-clickable div.
right: 'var(--window-controls-width)',
WebkitAppRegion: 'no-drag'
} as React.CSSProperties
}
>
{rightSidebarToggle}
</div>
)}
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
{shouldMountTerminalWorkbench ? (
<div
className={
!terminalWorkbenchVisible
? 'hidden flex-1 min-w-0 min-h-0'
: 'flex flex-1 min-w-0 min-h-0'
}
>
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId="terminal.workbench"
surface="terminal-workbench"
resetKey="terminal"
title={translate(
'auto.App.5a9519aef0',
'The workspace workbench hit an error.'
)}
description={translate(
'auto.App.98d4ea2823',
'Terminal, browser, or editor rendering failed in this workspace. Retry to remount it.'
)}
>
<Terminal />
</RecoverableRenderErrorBoundary>
</Suspense>
</div>
) : null}
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId={`page.${activeView}`}
surface="page"
resetKey={activeView}
title={translate('auto.App.b7a714db1e', 'This page hit an error.')}
description={translate(
'auto.App.03a14f6b5b',
'Retry the page or navigate to another Orca surface.'
)}
>
{activeView === 'settings' ? <Settings /> : null}
{activeView === 'skills' ? <SkillsPage /> : null}
{activeView === 'tasks' ? <TaskPage /> : null}
{activeView === 'automations' ? <AutomationsPage /> : null}
{activeView === 'activity' ? <ActivityPrototypePage /> : null}
{activeView === 'space' ? <WorkspaceSpacePage /> : null}
{activeView === 'mobile' ? <MobilePage /> : null}
{activeView === 'terminal' &&
creationLayoutActive &&
activePendingCreationId ? (
<WorktreeCreationPanel
creationId={activePendingCreationId}
reserveCollapsedSidebarHeaderSpace={
leftTitlebarChromeLayout.isFloating
}
/>
) : null}
{activeView === 'terminal' &&
!activeWorktreeId &&
!creationLayoutActive ? (
<Landing />
) : null}
</RecoverableRenderErrorBoundary>
</Suspense>
</div>
{showFloatingTerminalButton ? (
<FloatingTerminalToggleButton
open={floatingTerminalOpen}
onToggle={() => setFloatingTerminalOpenWithFocus((open) => !open)}
/>
) : null}
</div>
</div>
</div>
</div>
{/* Why: keep the right-sidebar shell mounted for layout stability.
Its heavy panels disconnect while closed so workspace wake stays
responsive. Unmount on the tasks view since that surface is
intentionally distraction-free. */}
{showRightSidebarControls ? (
<RecoverableRenderErrorBoundary
boundaryId="right-sidebar"
surface="right-sidebar"
resetKey={
rightSidebarTab === 'explorer'
? `${rightSidebarTab}:${rightSidebarExplorerView}`
: rightSidebarTab
}
title={translate('auto.App.ed6b168d00', 'The right sidebar hit an error.')}
description={translate(
'auto.App.8d1e160ed1',
'Retry the sidebar or switch tabs to reload this surface.'
)}
>
<RightSidebar />
</RecoverableRenderErrorBoundary>
) : null}
</div>
</RecoverableRenderErrorBoundary>
{shouldMountFloatingTerminalPanel ? (
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId="overlay.floating-workspace"
surface="overlay"
resetKey={floatingTerminalOpen}
compact
title={translate('auto.App.1b3024bcd6', 'The floating workspace hit an error.')}
description={translate(
'auto.App.7cbfbf622f',
'Retry the floating workspace or close and reopen it.'
)}
>
<FloatingTerminalPanel
open={floatingTerminalOpen}
onOpenChange={setFloatingTerminalOpenWithFocus}
tourInteractionSnapshot={floatingWorkspaceTourInteractionSnapshotRef.current}
/>
</RecoverableRenderErrorBoundary>
</Suspense>
) : null}
{statusBarVisible ? (
<Suspense
fallback={
<div className="h-6 min-h-[24px] shrink-0 border-t border-border bg-[var(--bg-titlebar,var(--card))]" />
}
>
<RecoverableRenderErrorBoundary
boundaryId="overlay.status-bar"
surface="overlay"
resetKey={activeView}
compact
title={translate('auto.App.2e8ff36f94', 'The status bar hit an error.')}
description={translate(
'auto.App.8a023cea1f',
'Retry the status bar to remount its controls.'
)}
>
<StatusBar floatingTerminalOpen={floatingTerminalOpen} />
</RecoverableRenderErrorBoundary>
</Suspense>
) : null}
{/* Why: workspace creation is a core action; keeping it in the
entry bundle avoids stale/corrupt lazy chunks stranding users at Create. */}
{activeModal === 'new-workspace-composer' ? (
<RecoverableRenderErrorBoundary
boundaryId="modal.new-workspace-composer"
surface="modal"
resetKey
compact
>
<NewWorkspaceComposerModal />
</RecoverableRenderErrorBoundary>
) : null}
<Suspense fallback={null}>
{shouldMountAddRepoDialog ? (
<RecoverableRenderErrorBoundary
boundaryId="modal.add-repo"
surface="modal"
resetKey={activeModal === 'add-repo'}
compact
>
<AddRepoDialog />
</RecoverableRenderErrorBoundary>
) : null}
{/* Why: Settings can start Add Project without mounting Sidebar,
so Add Project handoff dialogs must share the root host. */}
{activeModal === 'confirm-non-git-folder' ? (
<RecoverableRenderErrorBoundary
boundaryId="modal.confirm-non-git-folder"
surface="modal"
resetKey
compact
>
<NonGitFolderDialog />
</RecoverableRenderErrorBoundary>
) : null}
{activeModal === 'confirm-add-project-from-folder' ? (
<RecoverableRenderErrorBoundary
boundaryId="modal.confirm-add-project-from-folder"
surface="modal"
resetKey
compact
>
<AddProjectFromFolderDialog />
</RecoverableRenderErrorBoundary>
) : null}
{activeModal === 'project-added' ? (
<RecoverableRenderErrorBoundary
boundaryId="modal.project-added"
surface="modal"
resetKey
compact
>
<ProjectAddedDialog />
</RecoverableRenderErrorBoundary>
) : null}
</Suspense>
{/* Why: root overlays can render Radix <Tooltip>s; keep them inside
the shared provider so lazy surfaces mount safely from any entry point. */}
<Suspense fallback={null}>
{resolvedMountedLazyModalIds.has('workspace-cleanup') ? (
<RecoverableRenderErrorBoundary
boundaryId="modal.workspace-cleanup"
surface="modal"
resetKey={activeModal === 'workspace-cleanup'}
compact
>
<WorkspaceCleanupDialog />
</RecoverableRenderErrorBoundary>
) : null}
</Suspense>
<Suspense fallback={null}>
{resolvedMountedLazyModalIds.has('quick-open') ? (
<RecoverableRenderErrorBoundary
boundaryId="modal.quick-open"
surface="modal"
resetKey={activeModal === 'quick-open'}
compact
>
<QuickOpen />
</RecoverableRenderErrorBoundary>
) : null}
{resolvedMountedLazyModalIds.has('worktree-palette') ? (
<RecoverableRenderErrorBoundary
boundaryId="modal.worktree-palette"
surface="modal"
resetKey={activeModal === 'worktree-palette'}
compact
>
<WorktreeJumpPalette />
</RecoverableRenderErrorBoundary>
) : null}
{resolvedMountedLazyModalIds.has('setup-guide') ? (
<RecoverableRenderErrorBoundary
boundaryId="modal.setup-guide"
surface="modal"
resetKey={activeModal === 'setup-guide'}
compact
>
<SetupGuideModal />
</RecoverableRenderErrorBoundary>
) : null}
{resolvedMountedLazyModalIds.has('feature-wall') ? (
<RecoverableRenderErrorBoundary
boundaryId="modal.feature-wall"
surface="modal"
resetKey={activeModal === 'feature-wall'}
compact
>
<FeatureWallModal />
</RecoverableRenderErrorBoundary>
) : null}
{resolvedMountedLazyModalIds.has('feature-tips') ? (
<RecoverableRenderErrorBoundary
boundaryId="modal.feature-tips"
surface="modal"
resetKey={activeModal === 'feature-tips'}
compact
>
<FeatureTipsModal />
</RecoverableRenderErrorBoundary>
) : null}
</Suspense>
{shouldMountSetupGuideTelemetryObserver ? (
<Suspense fallback={null}>
<SetupGuideTelemetryObserver />
</Suspense>
) : null}
{shouldMountContextualTourOverlay ? (
<Suspense fallback={null}>
<ContextualTourOverlay />
</Suspense>
) : null}
{/* Why: mount PetOverlay only after persisted UI hydration, with
both independent pet toggles allowing it; otherwise a hidden pet
flashes while the store still has default visibility. */}
{renderPetOverlay ? (
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId="overlay.pet"
surface="overlay"
resetKey={petVisible}
compact
>
<PetOverlay />
</RecoverableRenderErrorBoundary>
</Suspense>
) : null}
{shouldMountUpdateCard ? (
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId="overlay.update-card"
surface="overlay"
resetKey={activeView}
compact
>
<UpdateCard />
</RecoverableRenderErrorBoundary>
</Suspense>
) : null}
<RecoverableRenderErrorBoundary
boundaryId="overlay.star-nag"
surface="overlay"
resetKey={activeView}
compact
>
<StarNagCard />
</RecoverableRenderErrorBoundary>
<RecoverableRenderErrorBoundary
boundaryId="overlay.star-nag-toast"
surface="overlay"
resetKey={activeView}
compact
>
<StarNagToastHost />
</RecoverableRenderErrorBoundary>
<StarNagAgentValueMomentObserver />
{/* Why: the existing-user opt-in banner mounts at App root so it
renders once per renderer session, not per view. It gates
internally on the cohort markers populated by the migration,
so it only shows for users who installed before the telemetry
release and have not yet resolved consent. New users get no
first-launch surface — see telemetry-plan.md §First-launch
experience. */}
<RecoverableRenderErrorBoundary
boundaryId="overlay.telemetry-first-launch"
surface="overlay"
resetKey={settings?.telemetry?.optedIn ?? 'unknown'}
compact
>
<TelemetryFirstLaunchSurface />
</RecoverableRenderErrorBoundary>
<RecoverableRenderErrorBoundary
boundaryId="overlay.zoom"
surface="overlay"
resetKey={activeView}
compact
>
<ZoomOverlay />
</RecoverableRenderErrorBoundary>
<Suspense fallback={null}>
{activeModal === 'delete-worktree' ? (
<RecoverableRenderErrorBoundary
boundaryId="modal.delete-worktree"
surface="modal"
resetKey
compact
>
<DeleteWorktreeDialog />
</RecoverableRenderErrorBoundary>
) : null}
</Suspense>
{hasSshCredentialRequest ? (
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId="modal.ssh-passphrase"
surface="modal"
resetKey={activeModal}
compact
>
<SshPassphraseDialog />
</RecoverableRenderErrorBoundary>
</Suspense>
) : null}
<RecoverableRenderErrorBoundary
boundaryId="modal.markdown-template-picker"
surface="modal"
resetKey={activeModal}
compact
>
<MarkdownTemplatePicker />
</RecoverableRenderErrorBoundary>
<RecoverableRenderErrorBoundary
boundaryId="modal.crash-report"
surface="modal"
reportAsCrash={false}
resetKey={activeModal}
compact
title={translate('auto.App.722d03aa62', 'The crash report dialog hit an error.')}
description={translate(
'auto.App.acd66311dc',
'Use the Help menu after retrying if you still need diagnostics.'
)}
>
<CrashReportDialog />
</RecoverableRenderErrorBoundary>
{onboarding && shouldRenderOnboarding && !onboardingSettingsDetourActive ? (
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId="modal.onboarding"
surface="modal"
resetKey={onboardingSettingsDetourActive}
title={translate('auto.App.f02d37278a', 'Onboarding hit an error.')}
description={translate(
'auto.App.221a95ba38',
'Retry onboarding or close it and continue in the app.'
)}
>
<OnboardingFlow
onboarding={onboarding}
onOnboardingChange={setOnboarding}
onSettingsDetourStart={beginOnboardingSettingsDetour}
/>
</RecoverableRenderErrorBoundary>
</Suspense>
) : null}
{shouldMountDictationController ? (
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId="overlay.dictation"
surface="overlay"
resetKey={activeView}
compact
>
<DictationController />
</RecoverableRenderErrorBoundary>
</Suspense>
) : null}
<RecoverableRenderErrorBoundary
boundaryId="overlay.recent-tab-switcher"
surface="overlay"
resetKey={activeView}
compact
>
<RecentTabSwitcher />
</RecoverableRenderErrorBoundary>
</LinkRoutingPreferenceDialogProvider>
</ConfirmationDialogProvider>
</TooltipProvider>
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
<PinnedTabCloseDialog />
{/* Why: rendered last so it sits after all -webkit-app-region:drag elements
in DOM order. Electron's hit-test for drag regions is DOM-order-based and
ignores z-index — placing WindowControls earlier caused the drag region to
win, making the buttons unclickable. */}
{isWindows && <WindowControls />}
</div>
)
}
export default App