mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
refactor(renderer): decompose App.tsx into an app-shell module (#14607)
* refactor(renderer): decompose App.tsx into an app-shell module App.tsx was 2831 lines behind an `eslint-disable max-lines` and a grandfathered entry in the max-lines ratchet baseline. It is now 92 lines: a root element, the shared providers, and three children. The body is split by concern into src/renderer/src/app-shell/: - use-app-chrome-layout — titlebar/sidebar/workbench layout derivations - use-floating-workspace-panel — overlay open state, persistence, return focus - use-app-startup-hydration — the boot chain (order preserved verbatim) - use-app-session-persistence — session writer + shutdown checkpoint - use-persisted-ui-writer / use-document-appearance / use-runtime-graph-sync / use-window-visibility-effects - use-onboarding-and-feature-tips — first-run education gating - use-global-keybindings + app-command-handlers — window shortcut dispatch - use-app-shell-services — app-level subscriptions that outlive any surface - AppWorkspaceShell / AppRootSurfaces / AppBackgroundServices / titlebar parts Two startup branches that no longer needed to sit inline moved to src/renderer/src/startup/: startup-ssh-connection-restore and startup-degraded-recovery. No behavior change. Sibling order of root overlays and modals is preserved so stacking is unchanged; the sidebar's virtualized scroll refs still live above the sidebar's remount boundary. The source-assertion tests in app-startup-routing.test.ts follow the code to its new files. The one that claimed to check "first-window startup services before terminal reconnect" was matching the degraded-recovery block, not the success path; it now asserts the success path, and the degraded ordering keeps its own dedicated test. Removes the disable comment and drops App.tsx from config/max-lines-baseline.txt. * refactor(renderer): move app-shell ref writes out of render React Doctor's changed-lines purity gate flags three render-phase ref writes in the new app-shell files. The patterns predate the split, but moving them into new files brings them into the gate. - use-app-chrome-layout: the terminal-workbench latch becomes state set during render (the pattern use-lazy-modal-mounts already uses). The `canMountTerminalWorkbenchNow ||` term keeps the current render correct, so the latch only has to be visible to the next one. - use-app-startup-hydration: the onboarding callback ref syncs in an effect declared before the boot chain, so it lands first on mount. The callback is a `useCallback([])`, so its identity never actually changes. - use-global-keybindings: the shortcut-state mirror syncs in useLayoutEffect, which commits before any key event can read it. Key events are discrete, so handlers always observe committed state. All three now hold committed state only, so nothing can leak from a render React discards — the behavior the gate is protecting. pnpm run check:react-doctor:changed: 0 errors (was 3).
This commit is contained in:
@@ -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
|
||||
|
||||
+41
-2780
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
<>
|
||||
<WorkspacePortScanner enabled={workspaceSessionReady} />
|
||||
{/* Why: plugin language-pack discovery must not re-render the App shell. */}
|
||||
<MacosTccPromptNoticeHost />
|
||||
{/* Why: leaf-mounted retention sync keeps agent-status subscriptions out of the App render tree. */}
|
||||
<RetainedAgentsSyncGate />
|
||||
<AiVaultTabTitleSyncGate />
|
||||
{dashboardPopoutEnabled ? (
|
||||
<Suspense fallback={null}>
|
||||
<DashboardPopoutBridge />
|
||||
</Suspense>
|
||||
) : null}
|
||||
<AgentHibernationGate />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<RecoverableRenderErrorBoundary surface="modal" compact {...props}>
|
||||
{children}
|
||||
</RecoverableRenderErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
function OverlayBoundary({ children, ...props }: BoundaryProps): React.JSX.Element {
|
||||
return (
|
||||
<RecoverableRenderErrorBoundary surface="overlay" compact {...props}>
|
||||
{children}
|
||||
</RecoverableRenderErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<Suspense fallback={null}>
|
||||
<OverlayBoundary
|
||||
boundaryId="overlay.floating-workspace"
|
||||
resetKey={floatingWorkspace.open}
|
||||
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={floatingWorkspace.open}
|
||||
onOpenChange={floatingWorkspace.setOpenWithFocus}
|
||||
tourInteractionSnapshot={floatingWorkspace.tourInteractionSnapshotRef.current}
|
||||
/>
|
||||
</OverlayBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
{statusBarVisible ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-6 min-h-[24px] shrink-0 border-t border-border bg-[var(--bg-titlebar,var(--card))]" />
|
||||
}
|
||||
>
|
||||
<OverlayBoundary
|
||||
boundaryId="overlay.status-bar"
|
||||
resetKey={activeView}
|
||||
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={floatingWorkspace.open} />
|
||||
</OverlayBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
{/* Why: keep in the entry bundle so a stale/corrupt lazy chunk can't strand users at Create. */}
|
||||
{activeModal === 'new-workspace-composer' ? (
|
||||
<ModalBoundary boundaryId="modal.new-workspace-composer" resetKey>
|
||||
<NewWorkspaceComposerModal />
|
||||
</ModalBoundary>
|
||||
) : null}
|
||||
<Suspense fallback={null}>
|
||||
{shouldMountAddRepoDialog ? (
|
||||
<ModalBoundary boundaryId="modal.add-repo" resetKey={activeModal === 'add-repo'}>
|
||||
<AddRepoDialog />
|
||||
</ModalBoundary>
|
||||
) : null}
|
||||
{/* Why: Settings can start Add Project without Sidebar, so its handoff dialogs must share the root host. */}
|
||||
{activeModal === 'confirm-non-git-folder' ? (
|
||||
<ModalBoundary boundaryId="modal.confirm-non-git-folder" resetKey>
|
||||
<NonGitFolderDialog />
|
||||
</ModalBoundary>
|
||||
) : null}
|
||||
{activeModal === 'confirm-add-project-from-folder' ? (
|
||||
<ModalBoundary boundaryId="modal.confirm-add-project-from-folder" resetKey>
|
||||
<AddProjectFromFolderDialog />
|
||||
</ModalBoundary>
|
||||
) : null}
|
||||
{activeModal === 'project-added' ? (
|
||||
<ModalBoundary boundaryId="modal.project-added" resetKey>
|
||||
<ProjectAddedDialog />
|
||||
</ModalBoundary>
|
||||
) : null}
|
||||
</Suspense>
|
||||
{/* Why: root overlays can render Radix <Tooltip>s; keep inside the shared provider so lazy surfaces mount from any entry point. */}
|
||||
<Suspense fallback={null}>
|
||||
{mountedLazyModalIds.has('workspace-cleanup') ? (
|
||||
<ModalBoundary
|
||||
boundaryId="modal.workspace-cleanup"
|
||||
resetKey={activeModal === 'workspace-cleanup'}
|
||||
>
|
||||
<WorkspaceCleanupDialog />
|
||||
</ModalBoundary>
|
||||
) : null}
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
{mountedLazyModalIds.has('quick-open') ? (
|
||||
<ModalBoundary boundaryId="modal.quick-open" resetKey={activeModal === 'quick-open'}>
|
||||
<QuickOpen />
|
||||
</ModalBoundary>
|
||||
) : null}
|
||||
{mountedLazyModalIds.has('worktree-palette') ? (
|
||||
<ModalBoundary
|
||||
boundaryId="modal.worktree-palette"
|
||||
resetKey={activeModal === 'worktree-palette'}
|
||||
>
|
||||
<WorktreeJumpPalette />
|
||||
</ModalBoundary>
|
||||
) : null}
|
||||
{mountedLazyModalIds.has('setup-guide') ? (
|
||||
<ModalBoundary boundaryId="modal.setup-guide" resetKey={activeModal === 'setup-guide'}>
|
||||
<SetupGuideModal />
|
||||
</ModalBoundary>
|
||||
) : null}
|
||||
{mountedLazyModalIds.has('feature-wall') ? (
|
||||
<ModalBoundary boundaryId="modal.feature-wall" resetKey={activeModal === 'feature-wall'}>
|
||||
<FeatureWallModal />
|
||||
</ModalBoundary>
|
||||
) : null}
|
||||
{mountedLazyModalIds.has('feature-tips') ? (
|
||||
<ModalBoundary boundaryId="modal.feature-tips" resetKey={activeModal === 'feature-tips'}>
|
||||
<FeatureTipsModal />
|
||||
</ModalBoundary>
|
||||
) : null}
|
||||
</Suspense>
|
||||
{shouldMountSetupGuideTelemetryObserver ? (
|
||||
<Suspense fallback={null}>
|
||||
<SetupGuideTelemetryObserver />
|
||||
</Suspense>
|
||||
) : null}
|
||||
{activeContextualTourId !== null ? (
|
||||
<Suspense fallback={null}>
|
||||
<ContextualTourOverlay />
|
||||
</Suspense>
|
||||
) : null}
|
||||
{/* Why: mount only after UI hydration, else a hidden pet flashes while the store still holds default visibility. */}
|
||||
{renderPetOverlay ? (
|
||||
<Suspense fallback={null}>
|
||||
<OverlayBoundary boundaryId="overlay.pet" resetKey={petVisible}>
|
||||
<PetOverlay />
|
||||
</OverlayBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
{shouldMountUpdateCard ? (
|
||||
<Suspense fallback={null}>
|
||||
<OverlayBoundary boundaryId="overlay.update-card" resetKey={activeView}>
|
||||
<UpdateCard />
|
||||
</OverlayBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
<OverlayBoundary boundaryId="overlay.star-nag" resetKey={activeView}>
|
||||
<StarNagCard />
|
||||
</OverlayBoundary>
|
||||
<OverlayBoundary boundaryId="overlay.star-nag-toast" resetKey={activeView}>
|
||||
<StarNagToastHost />
|
||||
</OverlayBoundary>
|
||||
<StarNagAgentValueMomentObserver />
|
||||
{/* 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. */}
|
||||
<OverlayBoundary
|
||||
boundaryId="overlay.telemetry-first-launch"
|
||||
resetKey={settings?.telemetry?.optedIn ?? 'unknown'}
|
||||
>
|
||||
<TelemetryFirstLaunchSurface />
|
||||
</OverlayBoundary>
|
||||
<OverlayBoundary boundaryId="overlay.zoom" resetKey={activeView}>
|
||||
<ZoomOverlay />
|
||||
</OverlayBoundary>
|
||||
<Suspense fallback={null}>
|
||||
{activeModal === 'delete-worktree' ? (
|
||||
<ModalBoundary boundaryId="modal.delete-worktree" resetKey>
|
||||
<DeleteWorktreeDialog />
|
||||
</ModalBoundary>
|
||||
) : null}
|
||||
{activeModal === 'preserved-branch-review' ? (
|
||||
<ModalBoundary boundaryId="modal.preserved-branch-review" resetKey>
|
||||
<PreservedBranchBatchReviewModal />
|
||||
</ModalBoundary>
|
||||
) : null}
|
||||
</Suspense>
|
||||
{hasSshCredentialRequest ? (
|
||||
<Suspense fallback={null}>
|
||||
<ModalBoundary boundaryId="modal.ssh-passphrase" resetKey={activeModal}>
|
||||
<SshPassphraseDialog />
|
||||
</ModalBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
<ModalBoundary boundaryId="modal.markdown-template-picker" resetKey={activeModal}>
|
||||
<MarkdownTemplatePicker />
|
||||
</ModalBoundary>
|
||||
<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>
|
||||
{onboardingGate.onboarding && onboardingGate.shouldRender ? (
|
||||
<Suspense fallback={null}>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.onboarding"
|
||||
surface="modal"
|
||||
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={onboardingGate.onboarding}
|
||||
onOnboardingChange={onboardingGate.setOnboarding}
|
||||
/>
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
{shouldMountDictationController ? (
|
||||
<Suspense fallback={null}>
|
||||
<OverlayBoundary boundaryId="overlay.dictation" resetKey={activeView}>
|
||||
<DictationController />
|
||||
</OverlayBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
<OverlayBoundary boundaryId="overlay.recent-tab-switcher" resetKey={activeView}>
|
||||
<RecentTabSwitcher />
|
||||
</OverlayBoundary>
|
||||
{/* Why: hosts a live terminal pane needing the link-routing preference context; mounting outside crashes it. */}
|
||||
<OverlayBoundary boundaryId="overlay.skill-freshness-update-dialog">
|
||||
<SkillFreshnessUpdateDialog />
|
||||
</OverlayBoundary>
|
||||
<Suspense fallback={null}>
|
||||
<OverlayBoundary boundaryId="overlay.remote-server-update-dialog">
|
||||
<RemoteServerUpdateDialog />
|
||||
</OverlayBoundary>
|
||||
</Suspense>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<number>
|
||||
scrollAnchorRef: React.MutableRefObject<VirtualizedScrollAnchor>
|
||||
}
|
||||
|
||||
function WorktreeSidebar({
|
||||
layout,
|
||||
scrollRefs
|
||||
}: {
|
||||
layout: AppChromeLayout
|
||||
scrollRefs: WorktreeSidebarScrollRefs
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="sidebar.worktrees"
|
||||
surface="sidebar"
|
||||
resetKey={layout.activeView}
|
||||
title={translate('auto.App.1468601e7b', 'The workspace list hit an error.')}
|
||||
description={
|
||||
layout.leftTitlebarChromeLayout.shouldMount
|
||||
? translate(
|
||||
'auto.App.bdc71dddc9',
|
||||
'The active workspace remains open. Retry the list or switch views.'
|
||||
)
|
||||
: translate(
|
||||
'auto.App.cba0fafda5',
|
||||
'The active page remains open. Retry the list or switch views.'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Sidebar
|
||||
worktreeScrollOffsetRef={scrollRefs.scrollOffsetRef}
|
||||
worktreeScrollAnchorRef={scrollRefs.scrollAnchorRef}
|
||||
/>
|
||||
</RecoverableRenderErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivePage({ layout }: { layout: AppChromeLayout }): React.JSX.Element {
|
||||
const { activeView, activeWorktreeId, activePendingCreationId, creationLayoutActive } = layout
|
||||
return (
|
||||
<>
|
||||
{activeView === 'settings' ? <Settings /> : null}
|
||||
{activeView === 'artifacts' ? <ArtifactsPage /> : 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={layout.leftTitlebarChromeLayout.isFloating}
|
||||
/>
|
||||
) : null}
|
||||
{activeView === 'terminal' && !activeWorktreeId && !creationLayoutActive ? <Landing /> : 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 = <TitlebarLeftControls layout={layout} />
|
||||
const titlebarMainStrip = <TitlebarMainStrip layout={layout} />
|
||||
// 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<VirtualizedScrollAnchor>(null)
|
||||
const sidebarScrollRefs = { scrollOffsetRef, scrollAnchorRef }
|
||||
|
||||
return (
|
||||
// Why: workspace activation is a hot path; activeWorktreeId in reset keys would remount whole surfaces during wake.
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="app.workspace-shell"
|
||||
surface="workspace-shell"
|
||||
resetKey={layout.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: keep the non-workspace titlebar inside this left+center wrapper so it doesn't span over the right-sidebar column. */}
|
||||
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
||||
{/* Why: workspace view drops the full-width titlebar so tab groups extend to the top; settings/landing/tasks keep it. */}
|
||||
{!layout.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">
|
||||
{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. */
|
||||
<div
|
||||
className={`flex min-h-0 flex-col shrink-0${layout.sidebarOpen ? '' : ' relative w-0 overflow-visible'}`}
|
||||
>
|
||||
<div
|
||||
// Why: floating titlebar-left occludes the center column's border-l seam; border-r restores that line, w-max sizes it to its own controls.
|
||||
className={`titlebar-left${
|
||||
layout.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; mirror those vars onto the header in the same left-column panel.
|
||||
...(layout.sidebarOpen ? layout.leftSidebarStyle : undefined),
|
||||
// Why: size from the wrapper's live width so the header tracks in-flight drag resizes (persisted to Zustand only on mouseup).
|
||||
width: layout.sidebarOpen ? '100%' : undefined
|
||||
}}
|
||||
>
|
||||
{titlebarLeftControls}
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<WorktreeSidebar layout={layout} scrollRefs={sidebarScrollRefs} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<WorktreeSidebar layout={layout} scrollRefs={sidebarScrollRefs} />
|
||||
)
|
||||
) : null}
|
||||
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
{/* Why: automations owns its page header; the stacked titlebar would be an empty 36px stripe. */}
|
||||
{layout.stackedSidebarOpen && layout.activeView !== 'automations' ? (
|
||||
<div className="titlebar">{titlebarMainStrip}</div>
|
||||
) : null}
|
||||
<div className="relative flex flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
{/* 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 && (
|
||||
<div
|
||||
className="absolute top-0 z-10 flex items-center h-[36px]"
|
||||
style={
|
||||
{
|
||||
// Why: --window-controls-width keeps the toggle clear of the fixed window-controls overlay (138px on custom chrome, 0px otherwise); no internal spacer — one would cover the pane-actions Ellipsis button with an unclickable div.
|
||||
right: 'var(--window-controls-width)',
|
||||
WebkitAppRegion: 'no-drag'
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{layout.showRightSidebarControls ? <RightSidebarToggle /> : null}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
|
||||
{layout.shouldMountTerminalWorkbench ? (
|
||||
<div
|
||||
className={
|
||||
layout.terminalWorkbenchVisible
|
||||
? 'flex flex-1 min-w-0 min-h-0'
|
||||
: 'hidden 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.${layout.activeView}`}
|
||||
surface="page"
|
||||
resetKey={layout.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.'
|
||||
)}
|
||||
>
|
||||
<ActivePage layout={layout} />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</Suspense>
|
||||
</div>
|
||||
{floatingWorkspace.showToggleButton ? (
|
||||
<FloatingTerminalToggleButton
|
||||
open={floatingWorkspace.open}
|
||||
onToggle={() => floatingWorkspace.setOpenWithFocus((open) => !open)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Why: keep the shell mounted for layout stability (heavy panels disconnect while closed); unmount on the distraction-free tasks view. */}
|
||||
{layout.showRightSidebarControls ? (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="right-sidebar"
|
||||
surface="right-sidebar"
|
||||
resetKey={
|
||||
layout.rightSidebarTab === 'explorer'
|
||||
? `${layout.rightSidebarTab}:${layout.rightSidebarExplorerView}`
|
||||
: layout.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>
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
<div
|
||||
ref={layout.titlebarLeftControlsRef}
|
||||
className={`flex h-full shrink-0 items-center${
|
||||
layout.leftTitlebarChromeLayout.isFloating ? ' w-max' : ' w-full'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-full items-center">
|
||||
{isMac && !layout.isFullScreen ? (
|
||||
<div className="titlebar-traffic-light-pad" />
|
||||
) : hasCustomTitleBar ? (
|
||||
/* Why: Windows/Linux remove the native title bar, so render the logo plus a ··· button that pops the application menu (as Alt does). */
|
||||
<>
|
||||
<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" />
|
||||
)}
|
||||
{layout.showSidebar && !hasCustomTitleBar && layout.showTitlebarAppName && (
|
||||
<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 updateSettings({ showTitlebarAppName: false })
|
||||
}}
|
||||
>
|
||||
{translate('auto.App.e81217c1b7', 'Hide App Name')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{layout.showSidebar && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="sidebar-toggle"
|
||||
onClick={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 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.
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="sidebar-toggle mr-2"
|
||||
onClick={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>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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' ? (
|
||||
<ActivityTitlebarControls />
|
||||
) : layout.creationLayoutActive ? null : (
|
||||
<div
|
||||
id="titlebar-tabs"
|
||||
className={`flex flex-1 min-w-0 self-stretch${!layout.workspaceChromeActive ? ' invisible pointer-events-none' : ''}`}
|
||||
/>
|
||||
)}
|
||||
{layout.showTitlebarExpandButton && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="titlebar-icon-button"
|
||||
onClick={handleToggleExpand}
|
||||
aria-label={translate('auto.App.c1cf0b0e4a', 'Collapse pane')}
|
||||
disabled={!layout.activeTabCanExpand}
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{translate('auto.App.c1cf0b0e4a', 'Collapse pane')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Why: the open right sidebar's header renders its own close button, so hide this duplicate. */}
|
||||
{layout.showRightSidebarControls && !layout.rightSidebarOpen ? <RightSidebarToggle /> : null}
|
||||
{/* Why: reserve space so the Windows/Linux window-controls overlay doesn't obscure content. */}
|
||||
{hasCustomTitleBar && <div className="window-controls-titlebar-spacer" />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<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: route close through main so the 'close' event fires the terminal-running confirmation guard; 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>
|
||||
)
|
||||
}
|
||||
@@ -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<typeof useAppStore.getState>
|
||||
|
||||
// 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<typeof useAppShortcutActions>
|
||||
|
||||
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<typeof usePluginCommands>
|
||||
setFloatingTerminalOpen: (open: boolean) => void
|
||||
terminalShortcutPolicy: NonNullable<AppStoreState['settings']>['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<KeybindingActionId, () => 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<AppShortcutActions['setRightSidebarTab']>[0]
|
||||
): boolean =>
|
||||
canRevealRightSidebar
|
||||
? claim(actionId, () => {
|
||||
actions.setRightSidebarTab(tab)
|
||||
actions.setRightSidebarOpen(true)
|
||||
})
|
||||
: false
|
||||
|
||||
return new Map<KeybindingActionId, () => 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')]
|
||||
])
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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<typeof useAppChromeLayout>
|
||||
|
||||
/**
|
||||
* 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<HTMLDivElement | null>(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
|
||||
}
|
||||
}
|
||||
@@ -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<typeof buildWorkspaceSessionHostSnapshots> = []
|
||||
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.
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<ExecutionHostId[]> {
|
||||
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])
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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<typeof useFloatingWorkspacePanel>
|
||||
|
||||
/**
|
||||
* 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<void>
|
||||
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<HTMLElement | null>(null)
|
||||
const returnFocusFrameRef = useRef<number | null>(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<boolean>): 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -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<LazyModalId>
|
||||
shouldMountAddRepoDialog: boolean
|
||||
} {
|
||||
const activeModal = useAppStore((s) => s.activeModal)
|
||||
const [mountedLazyModalIds, setMountedLazyModalIds] = useState<Set<LazyModalId>>(() => new Set())
|
||||
const [shouldMountAddRepoDialog, setShouldMountAddRepoDialog] = useState(false)
|
||||
const unmountAddRepoDialogTimerRef = useRef<ReturnType<typeof setTimeout> | 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 }
|
||||
}
|
||||
@@ -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<typeof useOnboardingAndFeatureTips>
|
||||
|
||||
/**
|
||||
* 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<OnboardingState | null>(null)
|
||||
const [onboardingLoaded, setOnboardingLoaded] = useState(false)
|
||||
const [featureTipCliInstalled, setFeatureTipCliInstalled] = useState<boolean | null>(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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
}, [])
|
||||
}
|
||||
@@ -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('<WorkspacePortScanner enabled={workspaceSessionReady} />')
|
||||
expect(readSource(SHELL_SERVICES_PATH)).toContain(
|
||||
'useGitStatusPolling({ enabled: workspaceSessionReady })'
|
||||
)
|
||||
expect(readSource(BACKGROUND_SERVICES_PATH)).toContain(
|
||||
'<WorkspacePortScanner enabled={workspaceSessionReady} />'
|
||||
)
|
||||
})
|
||||
|
||||
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('<Toaster closeButton')
|
||||
const appSource = readSource(APP_PATH)
|
||||
expect(appSource).toContain("import { Toaster } from '@/components/ui/sonner'")
|
||||
expect(appSource).not.toContain("import('@/components/ui/sonner')")
|
||||
expect(appSource).toContain('<Toaster closeButton')
|
||||
})
|
||||
|
||||
it('checkpoints activeView and all session snapshots through one beforeunload handler (#9002)', () => {
|
||||
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('
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<typeof useAppStore.getState>['hydratePersistedUI']
|
||||
reconnectPersistedTerminals: (signal: AbortSignal) => Promise<void>
|
||||
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<void> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user