perf(switch): do less work in the workspace-switch frame (#35185)

* perf(i18n): cache option-free translations per language and resource version

A workspace switch re-renders chrome that resolves hundreds of static strings
through i18next (~3 ms per switch). Option-free lookups only change with the
language or the loaded resources, so cache them keyed on both.

* perf(file-search): skip file search state writes that change nothing

Every workspace switch cancels the outgoing workspace's idle search, which wrote
an identical state object and re-notified every store subscriber.

* perf(sidebar): keep unchanged worktree cards and project headers memoized across switches

A plain press replaces the sidebar selection, and the selection-keyed gesture
callbacks plus the shared selectedWorktrees array were passed to every card, so
every card and its menus re-rendered on each workspace switch. Gesture callbacks
now read the latest selection from a ref, only selected rows receive the
selection, and project header actions are memoized.

* test(terminal): bound toast translations from above now that static strings are cached

* perf(terminal): stop re-rendering hidden panes and retained tab strips on workspace switches

Hidden panes re-measured their title-overlay rects on every switch, forcing a
layout and clearing the rects, which re-rendered the pane once on hide and again
on show. Hidden overlays are display:none, so they keep their last rects until
shown. Retained tab strips subscribed to the active repo and worktree for a
Windows-only shell menu, re-rendering every strip; those reads are gated like
the project maps beside them.

* perf(app-shell): let root overlays skip the App render a workspace switch causes

App re-renders on every switch (its chrome layout tracks the active worktree),
and it handed fresh floating-workspace and onboarding objects to the root
overlays, re-rendering every overlay and dialog. Both objects are memoized,
AppRootSurfaces and the floating toggle button are memo components, and the
toggle callback is stable.

* perf(worktrees): switch the active repo and worktree in one store write

Activating a worktree in another project wrote activeRepoId and then the
active worktree separately, so every store subscriber re-ran its selector twice
(~1 ms per switch). The repo change now rides along as a state transition of the
same setActiveWorktree write, as the SSH server-copy activation already does.

* perf(app-shell): stop App re-rendering on workspace switches

App subscribed to the raw active worktree and tab ids through the chrome layout
and three sync hooks, so every switch re-rendered the whole app shell. The
layout now exposes booleans, shortcut dispatch and the titlebar expand button
read the active ids when they fire, and the git-status polling, web session tab
sync and review refresh reporting hooks run in a null gate component under
AppBackgroundServices.

* perf(worktrees): fold visit recency and nav history into the activation write

A switch wrote the active worktree, then the leave/enter recency stamps and the
nav-history entry as separate store writes, each re-running every subscriber's
selector. The stamps (resolved against the post-activation selection, as
markVisited did) and the history entry now ride in the same setActiveWorktree
state transition as the repo follow.

* perf(terminal): present instead of clearing the shared glyph atlas on worktree reveal

The settled reveal reset predates xterm's per-renderer page-layout versioning
(renderers now rebuild their model when a shared atlas page changes), so a
healthy atlas no longer needs re-rasterizing on every worktree switch. DPR
repair and system wake keep the full reset.

* fix(worktrees): keep fresh visit stamps when the activation's history entry is de-duplicated

* fix(app-shell): repaint memoized shell controls on a language change

* test(floating-terminal): stub the language subscription in the toggle button's hook harness

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-28 09:08:11 -07:00
committed by GitHub
co-authored by m4air
parent f81700bd04
commit 8db8905fac
32 changed files with 446 additions and 179 deletions
+2 -1
View File
@@ -3,7 +3,8 @@
# This is a RATCHET: counts may only go DOWN. Only creation flows with no source row may read
# the default host, through defaultCreationHost. Everything else routes by the owner.
# Prune after removing reads: pnpm check:focus-read-ratchet --prune
3 src/renderer/src/app-shell/use-app-shell-services.ts
1 src/renderer/src/app-shell/ActiveWorkspaceSyncGate.tsx
2 src/renderer/src/app-shell/use-app-shell-services.ts
2 src/renderer/src/App.tsx
1 src/renderer/src/component-fixtures/component-fixture-explorer.tsx # dev-only fixture page seeds default settings
1 src/renderer/src/components/activity/activity-scope-filter-controls.tsx
@@ -0,0 +1,17 @@
import { useAppStore } from '../store'
import { useEditorExternalWatch } from '../hooks/useEditorExternalWatch'
import { useGitStatusPolling } from '../components/right-sidebar/useGitStatusPolling'
import { useWebSessionTabsSync } from '../runtime/web-session-tabs-sync/use-web-session-tabs-sync'
import { useVisibleReviewRefreshReporting } from './use-visible-review-refresh-reporting'
/** Hosts services that track the active workspace, so a switch re-renders this null node instead of App. */
export function ActiveWorkspaceSyncGate(): null {
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
useWebSessionTabsSync()
useVisibleReviewRefreshReporting()
// Poll above the removable sidebar to keep rebase/merge badges fresh; wait for session readiness to protect first paint.
useGitStatusPolling({ enabled: workspaceSessionReady })
// Watch files above Explorer so editor changes still arrive on Source Control/Checks.
useEditorExternalWatch()
return null
}
@@ -7,6 +7,7 @@ import { WorkspacePortScanner } from '../components/ports/WorkspacePortScanner'
import { SavedHostScopeWidenGate } from '../components/sidebar/SavedHostScopeWidenGate'
import { MacosTccPromptNoticeHost } from '../hooks/MacosTccPromptNoticeHost'
import { useAppStore } from '../store'
import { ActiveWorkspaceSyncGate } from './ActiveWorkspaceSyncGate'
import { StructuredAgentSessionAttentionBridge } from '../components/native-chat/StructuredAgentSessionAttentionBridge'
import { StructuredAgentSessionStatusBridge } from '../components/native-chat/StructuredAgentSessionStatusBridge'
@@ -22,6 +23,7 @@ export function AppBackgroundServices(): React.JSX.Element {
return (
<>
<WorkspacePortScanner enabled={workspaceSessionReady} />
<ActiveWorkspaceSyncGate />
{/* Keep permission notices available across app surfaces. */}
<MacosTccPromptNoticeHost />
{/* Keep agent-status retention subscriptions out of the App render tree. */}
@@ -1,8 +1,9 @@
import { useTranslation } from 'react-i18next'
import { ModalBoundary, OverlayBoundary } from './app-root-surface-boundaries'
import { OnboardingLanguageLoading } from '../components/onboarding/OnboardingLanguageLoading'
import { useLanguageReadiness } from '../i18n/language-readiness'
import { NotificationCardStack } from '../components/NotificationCardStack'
import { Suspense } from 'react'
import { memo, Suspense } from 'react'
import { createPortal } from 'react-dom'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { translate } from '@/i18n/i18n'
@@ -108,12 +109,14 @@ function shouldMountUpdateCardForStatus(status: UpdateStatus): boolean {
return true
}
/** Keep root overlays in stable sibling order; fetch gated chunks only when their surfaces can appear. */
export function AppRootSurfaces(props: {
/** Keep root overlays in stable sibling order; fetch gated chunks only when their surfaces can appear. Memoized so active-workspace App renders skip every root overlay. */
export const AppRootSurfaces = memo(function AppRootSurfaces(props: {
floatingWorkspace: FloatingWorkspacePanelState
onboardingGate: OnboardingGate
}): React.JSX.Element {
const { floatingWorkspace, onboardingGate } = props
// Memoized: subscribe so a language change still repaints its labels.
useTranslation()
const languageReady = useLanguageReadiness((state) => state.ready)
const { mountedLazyModalIds, shouldMountAddRepoDialog } = useLazyModalMounts()
usePreloadWorktreeJumpPalette()
@@ -398,4 +401,4 @@ export function AppRootSurfaces(props: {
</Suspense>
</>
)
}
})
@@ -1,4 +1,4 @@
import { Suspense, useRef } from 'react'
import { memo, Suspense, useCallback, useRef } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { translate } from '@/i18n/i18n'
import Sidebar from '../components/Sidebar'
@@ -12,6 +12,9 @@ import { RightSidebarToggle, TitlebarMainStrip } from './TitlebarMainStrip'
import type { AppChromeLayout } from './use-app-chrome-layout'
import type { FloatingWorkspacePanelState } from './use-floating-workspace-panel'
// Skips the shell render each workspace switch causes; its props only change on toggle.
const MemoFloatingTerminalToggleButton = memo(FloatingTerminalToggleButton)
const Landing = lazy(() => import('../components/Landing'))
const WorktreeCreationPanel = lazy(
() => import('../components/worktree-creation/WorktreeCreationPanel')
@@ -65,7 +68,7 @@ function WorktreeSidebar({
}
function ActivePage({ layout }: { layout: AppChromeLayout }): React.JSX.Element {
const { activeView, activeWorktreeId, activePendingCreationId, creationLayoutActive } = layout
const { activeView, hasActiveWorktree, activePendingCreationId, creationLayoutActive } = layout
return (
<>
{activeView === 'settings' ? <Settings /> : null}
@@ -82,7 +85,9 @@ function ActivePage({ layout }: { layout: AppChromeLayout }): React.JSX.Element
reserveCollapsedSidebarHeaderSpace={layout.leftTitlebarChromeLayout.isFloating}
/>
) : null}
{activeView === 'terminal' && !activeWorktreeId && !creationLayoutActive ? <Landing /> : null}
{activeView === 'terminal' && !hasActiveWorktree && !creationLayoutActive ? (
<Landing />
) : null}
</>
)
}
@@ -98,6 +103,11 @@ export function AppWorkspaceShell(props: {
const scrollOffsetRef = useRef(0)
const scrollAnchorRef = useRef<VirtualizedScrollAnchor>(null)
const sidebarScrollRefs = { scrollOffsetRef, scrollAnchorRef }
const { setOpenWithFocus } = floatingWorkspace
const toggleFloatingWorkspace = useCallback(
() => setOpenWithFocus((open) => !open),
[setOpenWithFocus]
)
return (
// Avoid active-worktree reset keys that remount whole surfaces during wake.
@@ -214,9 +224,9 @@ export function AppWorkspaceShell(props: {
</Suspense>
</div>
{floatingWorkspace.showToggleButton ? (
<FloatingTerminalToggleButton
<MemoFloatingTerminalToggleButton
open={floatingWorkspace.open}
onToggle={() => floatingWorkspace.setOpenWithFocus((open) => !open)}
onToggle={toggleFloatingWorkspace}
/>
) : null}
</div>
@@ -5,6 +5,7 @@ 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 { selectActiveTerminalChromeState } from '../store/active-terminal-chrome-selector'
import { hasCustomTitleBar } from './app-window-chrome'
import type { AppChromeLayout } from './use-app-chrome-layout'
@@ -33,12 +34,13 @@ export function RightSidebarToggle(): React.JSX.Element {
export function TitlebarMainStrip({ layout }: { layout: AppChromeLayout }): React.JSX.Element {
const handleToggleExpand = (): void => {
if (!layout.effectiveActiveTabId) {
const { effectiveActiveTabId } = selectActiveTerminalChromeState(useAppStore.getState())
if (!effectiveActiveTabId) {
return
}
window.dispatchEvent(
new CustomEvent(TOGGLE_TERMINAL_PANE_EXPAND_EVENT, {
detail: { tabId: layout.effectiveActiveTabId }
detail: { tabId: effectiveActiveTabId }
})
)
}
@@ -32,13 +32,19 @@ export function useAppChromeLayout() {
s.activePendingCreationId !== null &&
s.pendingWorktreeCreations[s.activePendingCreationId] !== undefined
)
const {
activeWorktreeId,
tabCount,
effectiveActiveTabId,
activeTabCanExpand,
effectiveActiveTabExpanded
} = useAppStore(useShallow(selectActiveTerminalChromeState))
// Booleans only: App owns this hook, and raw active ids would re-render the whole app on every workspace switch.
const { hasActiveWorktree, hasTabBar, activeTabCanExpand, effectiveActiveTabExpanded } =
useAppStore(
useShallow((s) => {
const chrome = selectActiveTerminalChromeState(s)
return {
hasActiveWorktree: chrome.activeWorktreeId !== null,
hasTabBar: chrome.tabCount >= 2,
activeTabCanExpand: chrome.activeTabCanExpand,
effectiveActiveTabExpanded: chrome.effectiveActiveTabExpanded
}
})
)
const backgroundTerminalMountRequested = useSyncExternalStore(
subscribeBackgroundTerminalWorktreeMountRequests,
hasRequestedBackgroundTerminalWorktreeMount,
@@ -56,7 +62,7 @@ export function useAppChromeLayout() {
(s) => (s.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.length ?? 0) > 0
)
const canMountTerminalWorkbenchNow =
activeWorktreeId !== null || backgroundTerminalMountRequested || floatingWorkspaceHasTabs
hasActiveWorktree || backgroundTerminalMountRequested || floatingWorkspaceHasTabs
// Use state so discarded renders cannot leak the mount latch; the OR below covers this render.
const [hasMountedTerminalWorkbench, setHasMountedTerminalWorkbench] = useState(false)
if (canMountTerminalWorkbenchNow && !hasMountedTerminalWorkbench) {
@@ -71,8 +77,7 @@ export function useAppChromeLayout() {
hasActivePendingCreation: activePendingCreationExists
})
const workspaceChromeActive =
activeView === 'terminal' && activeWorktreeId !== null && !creationLayoutActive
const hasTabBar = tabCount >= 2
activeView === 'terminal' && hasActiveWorktree && !creationLayoutActive
// Activity/Space own full pages and hide the workspace sidebar.
const showSidebar =
activeView !== 'settings' && activeView !== 'activity' && activeView !== 'space'
@@ -120,12 +125,11 @@ export function useAppChromeLayout() {
return {
activeView,
activeWorktreeId,
activePendingCreationId,
activeTabCanExpand,
effectiveActiveTabId,
collapsedSidebarHeaderWidth,
creationLayoutActive,
hasActiveWorktree,
isFullScreen,
leftSidebarStyle,
leftTitlebarChromeLayout,
@@ -3,7 +3,6 @@ import { useClosedEditorTabCleanup } from '../components/editor/useClosedEditorT
import { useIpcEvents } from '../hooks/useIpcEvents'
import { useAutomationDispatchEvents } from '../hooks/useAutomationDispatchEvents'
import { useReadOnScreenAttention } from '../hooks/useReadOnScreenAttention'
import { useEditorExternalWatch } from '../hooks/useEditorExternalWatch'
import { useAppMenuPaste } from '../hooks/useAppMenuPaste'
import { useAppMenuSelectionActions } from '../hooks/useAppMenuSelectionActions'
import { useLargeTextControlPaste } from '../hooks/useLargeTextControlPaste'
@@ -12,9 +11,7 @@ import {
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/use-web-session-tabs-sync'
import { useLocalStructuredSessionTabsSync } from '../runtime/local-structured-session-tabs-sync'
import { useHostStructuredAgentsSync } from '../runtime/host-structured-agents-sync'
import { useLegacyChatRenameMigration } from '../runtime/legacy-chat-rename-migration'
@@ -25,7 +22,6 @@ import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/br
import { useBrowserClientHostedDownloadNotices } from '../components/browser-pane/browser-client-hosted-download-notices'
import { useCodexTerminalServerIsolationNotice } from '../components/terminal-pane/codex-terminal-server-isolation-notice'
import { useCodexSharedSettingsNotice } from '../components/terminal-pane/codex-shared-settings-notice'
import { useVisibleReviewRefreshReporting } from './use-visible-review-refresh-reporting'
import { useVisibleHostedReviewRefresh } from './use-visible-hosted-review-refresh'
import { useProfileStateSaveDelayNotice } from './use-profile-state-save-delay-notice'
@@ -39,7 +35,6 @@ export function useAppShellServices(): void {
useClosedEditorTabCleanup()
useRadixBodyPointerEventsRecovery()
useWebSessionTabsSync()
useLocalStructuredSessionTabsSync()
useHostStructuredAgentsSync()
useLegacyChatRenameMigration()
@@ -47,14 +42,9 @@ export function useAppShellServices(): void {
useIpcEvents()
useProfileStateSaveDelayNotice()
useRemoteRuntimeRecoveryTriggers()
useVisibleReviewRefreshReporting()
useVisibleHostedReviewRefresh({ enabled: workspaceSessionReady })
useTerminalViewerColorPublication()
useAutomationDispatchEvents()
// Poll above the removable sidebar to keep rebase/merge badges fresh; wait for session readiness to protect first paint.
useGitStatusPolling({ enabled: workspaceSessionReady })
// Watch files above Explorer so editor changes still arrive on Source Control/Checks.
useEditorExternalWatch()
useReadOnScreenAttention()
useAppMenuPaste()
useAppMenuSelectionActions()
@@ -1,4 +1,11 @@
import { useCallback, useEffect, useLayoutEffect, useRef, type SetStateAction } from 'react'
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
type SetStateAction
} from 'react'
import {
TOGGLE_FLOATING_TERMINAL_EVENT,
requestFloatingTerminalOpenMaximized
@@ -116,16 +123,31 @@ export function useFloatingWorkspacePanel() {
}
}, [settingsHydrated, enabled, setOpenWithFocus])
return {
cancelReturnFocusFrame,
enabled,
open,
openMaximized,
setOpenWithFocus,
// Retain panes’ local state while closed once the floating workspace owns tabs.
shouldMountPanel: enabled && (open || visibleTabCount > 0),
showToggleButton: enabled && (triggerLocation === 'floating-button' || !statusBarVisible),
tourInteractionSnapshotRef,
visibleTabCount
}
const shouldMountPanel = enabled && (open || visibleTabCount > 0)
const showToggleButton = enabled && (triggerLocation === 'floating-button' || !statusBarVisible)
// Stable identity lets memoized root surfaces skip the App render each workspace switch causes.
return useMemo(
() => ({
cancelReturnFocusFrame,
enabled,
open,
openMaximized,
setOpenWithFocus,
// Retain panes’ local state while closed once the floating workspace owns tabs.
shouldMountPanel,
showToggleButton,
tourInteractionSnapshotRef,
visibleTabCount
}),
[
cancelReturnFocusFrame,
enabled,
open,
openMaximized,
setOpenWithFocus,
shouldMountPanel,
showToggleButton,
visibleTabCount
]
)
}
@@ -47,9 +47,8 @@ export function useGlobalKeybindings(args: {
const terminalShortcutPolicy = useAppStore((s) => s.settings?.terminalShortcutPolicy)
const pluginCommands = usePluginCommands()
const shortcutState: AppShortcutState = {
const shortcutState: Omit<AppShortcutState, 'activeWorktreeId'> = {
activeView: layout.activeView,
activeWorktreeId: layout.activeWorktreeId,
actions,
creationLayoutActive: layout.creationLayoutActive,
floatingTerminalEnabled: floatingWorkspace.enabled,
@@ -71,12 +70,17 @@ export function useGlobalKeybindings(args: {
useEffect(() => {
const doubleTapDetector = new ModifierDoubleTapDetector()
// The active worktree is read at dispatch: App deliberately does not re-render on workspace switches.
const readShortcutState = (): AppShortcutState => ({
...shortcutStateRef.current,
activeWorktreeId: useAppStore.getState().activeWorktreeId
})
const unregisterAppCommandDispatcher = registerAppCommandDispatcher((actionId) =>
(createAppCommandHandlers(shortcutStateRef.current).get(actionId) ?? (() => false))()
(createAppCommandHandlers(readShortcutState()).get(actionId) ?? (() => false))()
)
const dispatchShortcutInput = (input: ShortcutDispatchInput): void => {
const state = shortcutStateRef.current
const state = readShortcutState()
const {
activeView,
activeWorktreeId,
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, 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'
@@ -166,10 +166,9 @@ export function useOnboardingAndFeatureTips() {
settings
])
return {
applyStartupOnboardingState,
onboarding,
setOnboarding,
shouldRender: onboarding !== null && shouldShowOnboarding(onboarding)
}
const shouldRender = onboarding !== null && shouldShowOnboarding(onboarding)
return useMemo(
() => ({ applyStartupOnboardingState, onboarding, setOnboarding, shouldRender }),
[applyStartupOnboardingState, onboarding, shouldRender]
)
}
@@ -1,4 +1,5 @@
import type * as ReactModule from 'react'
import type * as ReactI18nextModule from 'react-i18next'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FLOATING_TERMINAL_TRIGGER_POSITION_STORAGE_KEY } from './floating-terminal-trigger-position'
@@ -53,6 +54,11 @@ vi.mock('react', async () => {
}
})
vi.mock('react-i18next', async (importOriginal) => ({
...(await importOriginal<typeof ReactI18nextModule>()),
useTranslation: () => ({})
}))
vi.mock('lucide-react', () => ({
PanelsTopLeft: function PanelsTopLeft() {
return null
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { PanelsTopLeft } from 'lucide-react'
import { Button } from '@/components/ui/button'
@@ -63,6 +64,8 @@ export function FloatingTerminalToggleButton({
open: boolean
onToggle: () => void
}): React.JSX.Element {
// Memoized: subscribe so a language change still repaints its labels.
useTranslation()
const shortcutLabel = useShortcutLabel('floatingTerminal.toggle')
// Read shared unread state so engaging with or closing a tab clears its minimized-panel attention dot.
const hasFloatingUnread = useAppStore(selectFloatingWorkspaceHasUnread)
@@ -119,6 +119,7 @@ export function renderWorktreeItemRow(
: undefined
const worktreeDragGroupKey = ctx.groupKeyByRowKey.get(itemRow.rowKey)
const worktreeIdentity = getWorktreeHostIdentity(itemRow.worktree)
const isMultiSelected = ctx.selectedWorktreeIds.has(worktreeIdentity)
const isLineageDropTarget =
ctx.worktreeDragState.draggingWorktreeId &&
(ctx.worktreeDragState.lineageDropTargetId === itemRow.worktree.id ||
@@ -132,7 +133,7 @@ export function renderWorktreeItemRow(
key={itemRow.rowKey}
id={getWorktreeOptionId(itemRow.rowKey)}
role="option"
aria-selected={ctx.selectedWorktreeIds.has(worktreeIdentity)}
aria-selected={isMultiSelected}
aria-current={isActiveWorktree ? 'page' : undefined}
data-worktree-id={itemRow.worktree.id}
data-worktree-host-identity={worktreeIdentity}
@@ -181,12 +182,13 @@ export function renderWorktreeItemRow(
activeSurfaceVariant={
isActiveWorktree && !forceActiveSurface ? ctx.getActiveSurfaceVariant(itemRow) : 'primary'
}
isMultiSelected={ctx.selectedWorktreeIds.has(worktreeIdentity)}
isMultiSelected={isMultiSelected}
revealHighlight={ctx.highlightedRevealRowKey === itemRow.rowKey}
revealHighlightTone={
ctx.agentSendTargetWorktreeId === itemRow.worktree.id ? 'ai' : 'default'
}
selectedWorktrees={ctx.selectedWorktrees}
// Only selected rows act on the selection; passing it to the rest would re-render every card per click.
selectedWorktrees={isMultiSelected ? ctx.selectedWorktrees : undefined}
nativeDragEnabled={false}
isLineageDropTarget={Boolean(isLineageDropTarget)}
contentIndent={cardContentIndent}
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import React from 'react'
import { useRepoSshGate } from '@/hooks/use-repo-ssh-gate'
import {
@@ -78,7 +79,8 @@ export type RepoHeaderProjectActions = {
onCreateForRepo: (projectId: string) => void
}
export function RepoHeaderProjectActionsMenu({
// Memoized: the sidebar re-renders every header row when the active workspace changes.
export const RepoHeaderProjectActionsMenu = React.memo(function RepoHeaderProjectActionsMenu({
repo,
label,
projectGroups,
@@ -89,6 +91,8 @@ export function RepoHeaderProjectActionsMenu({
projectGroups: readonly ProjectGroup[]
actions: RepoHeaderProjectActions
}): React.JSX.Element {
// Memoized: subscribe so a language change still repaints its labels.
useTranslation()
return (
<DropdownMenu modal={false}>
<Tooltip>
@@ -184,9 +188,9 @@ export function RepoHeaderProjectActionsMenu({
</DropdownMenuContent>
</DropdownMenu>
)
}
})
export function RepoHeaderCreateWorkspaceButton({
export const RepoHeaderCreateWorkspaceButton = React.memo(function RepoHeaderCreateWorkspaceButton({
repo,
label,
onCreateForRepo
@@ -195,6 +199,8 @@ export function RepoHeaderCreateWorkspaceButton({
label: string
onCreateForRepo: (projectId: string) => void
}): React.JSX.Element {
// Memoized: subscribe so a language change still repaints its labels.
useTranslation()
const sshGate = useRepoSshGate(repo)
const createState = getRepoHeaderCreateState({ repo, label, sshGate })
const fallbackLabel = translate(
@@ -254,4 +260,4 @@ export function RepoHeaderCreateWorkspaceButton({
</TooltipContent>
</Tooltip>
)
}
})
@@ -28,6 +28,8 @@ import { useWorktreeSidebarScrollSuppression } from './use-scroll-suppression'
import { EMPTY_PROJECT_GROUPS, type VirtualizedWorktreeViewportProps } from './viewport-props'
import { useWorktreeDropCommitContext } from '../drag/use-drop-commit-context'
import { buildWorktreeVirtualRowContext } from './virtual-row-context'
import type { RepoHeaderProjectActions } from '../rows/repo-header-project-actions'
import { getRepoOwnerWorktreeVisibilityDefaults } from '../../../../store/worktree-visibility-defaults-by-host'
import { renderWorktreeVirtualRow } from '../rows/virtual-row-dispatch'
const EMPTY_HOST_OPTIONS: NonNullable<VirtualizedWorktreeViewportProps['hostOptions']> = []
@@ -68,6 +70,40 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr
projectGrouping: props.projectGrouping
})
const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true
const {
handleOpenRepoSettings,
handleOpenWorktreeVisibility,
handleCreateGroupFromRepo,
handleMoveProjectToGroup,
handleRemoveProjectFromGroup,
handleRemoveProject,
handleCreateForRepo
} = props
// Why stable: a new object re-renders every project header's menu tree on each workspace switch.
const projectActions = useMemo<RepoHeaderProjectActions>(
() => ({
getWorktreeVisibilityDefaults: (repo) =>
getRepoOwnerWorktreeVisibilityDefaults(repo, settings, worktreeVisibilityDefaultsByHost),
onOpenRepoSettings: handleOpenRepoSettings,
onOpenWorktreeVisibility: handleOpenWorktreeVisibility,
onCreateGroupFromRepo: handleCreateGroupFromRepo,
onMoveProjectToGroup: handleMoveProjectToGroup,
onRemoveProjectFromGroup: handleRemoveProjectFromGroup,
onRemoveProject: handleRemoveProject,
onCreateForRepo: handleCreateForRepo
}),
[
settings,
worktreeVisibilityDefaultsByHost,
handleOpenRepoSettings,
handleOpenWorktreeVisibility,
handleCreateGroupFromRepo,
handleMoveProjectToGroup,
handleRemoveProjectFromGroup,
handleRemoveProject,
handleCreateForRepo
]
)
const reveal = useSidebarRevealHighlight()
const scrollSuppression = useWorktreeSidebarScrollSuppression(scrollRef)
@@ -308,7 +344,7 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr
virtualization,
measureVirtualRowElement,
settings,
worktreeVisibilityDefaultsByHost,
projectActions,
newCardStyle,
folderBackedProjectGroupIds,
projectGroups,
@@ -15,7 +15,7 @@ import type { WorktreeSidebarHeaderDrag } from '../drag/use-header-drag'
import type { WorktreeListVirtualizer } from './use-virtualizer'
import type { VirtualizedWorktreeViewportProps } from './viewport-props'
import type { WorktreeVirtualRowContext } from '../rows/virtual-row-dispatch'
import { getRepoOwnerWorktreeVisibilityDefaults } from '../../../../store/worktree-visibility-defaults-by-host'
import type { RepoHeaderProjectActions } from '../rows/repo-header-project-actions'
import type { ProjectHeaderHostContext } from '../rows/project-header-host-context'
@@ -27,7 +27,7 @@ type BuildArgs = {
virtualization: WorktreeListVirtualizer
measureVirtualRowElement: (element: HTMLDivElement | null) => void
settings: AppState['settings']
worktreeVisibilityDefaultsByHost: AppState['worktreeVisibilityDefaultsByHost']
projectActions: RepoHeaderProjectActions
newCardStyle: boolean
folderBackedProjectGroupIds: ReadonlySet<string>
session: WorktreeDragSession
@@ -85,21 +85,7 @@ export function buildWorktreeVirtualRowContext(args: BuildArgs): WorktreeVirtual
projectHeaderHostContexts: args.projectHeaderHostContexts,
getCachedFolderWorkspacePathStatus: args.getCachedFolderWorkspacePathStatus,
toggleGroupWithScrollAnchor: args.toggleGroupWithScrollAnchor,
projectActions: {
getWorktreeVisibilityDefaults: (repo) =>
getRepoOwnerWorktreeVisibilityDefaults(
repo,
args.settings,
args.worktreeVisibilityDefaultsByHost
),
onOpenRepoSettings: props.handleOpenRepoSettings,
onOpenWorktreeVisibility: props.handleOpenWorktreeVisibility,
onCreateGroupFromRepo: props.handleCreateGroupFromRepo,
onMoveProjectToGroup: props.handleMoveProjectToGroup,
onRemoveProjectFromGroup: props.handleRemoveProjectFromGroup,
onRemoveProject: props.handleRemoveProject,
onCreateForRepo: props.handleCreateForRepo
},
projectActions: args.projectActions,
onRenameProjectGroup: props.handleRenameProjectGroup,
onDeleteProjectGroup: props.handleDeleteProjectGroup,
onCreateFolderWorkspace: props.handleCreateFolderWorkspace,
@@ -121,8 +121,6 @@ export function useTabBarRuntimeModel({
const defaultWindowsPowerShellImplementation = useAppStore((s) =>
resolveWindowsPowerShellImplementationSetting(s.settings)
)
const activeRepoId = useAppStore((s) => s.activeRepoId)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const settings = useAppStore((s) => s.settings)
// Why: use the worktree's owning host so offered Windows shells match the host that actually runs the terminal.
const activeRuntimeEnvironmentId = useAppStore((s) =>
@@ -176,6 +174,11 @@ export function useTabBarRuntimeModel({
const worktreesByRepo = useAppStore((s) =>
needsLocalProjectRuntime ? selectWorktreesByRepo(s) : EMPTY_WORKTREES_BY_REPO
)
// Gated like the maps above: every retained strip would otherwise re-render on each workspace switch.
const activeRepoId = useAppStore((s) => (needsLocalProjectRuntime ? s.activeRepoId : null))
const activeWorktreeId = useAppStore((s) =>
needsLocalProjectRuntime ? s.activeWorktreeId : null
)
const localProjectRuntime = useMemo(() => {
if (!needsLocalProjectRuntime) {
return undefined
@@ -123,7 +123,8 @@ describe('humanizeTerminalError', () => {
if (kind === 'startup') {
expect(tokenChecks).toBe(2 * rowCount)
}
expect(startupTranslations).toBe(2 * matchingRows)
// Upper bound only: option-free translations are cached after their first lookup.
expect(startupTranslations).toBeLessThanOrEqual(2 * matchingRows)
expect(
view.container.textContent?.match(/Codex exited while starting\./g) ?? []
).toHaveLength(matchingRows)
@@ -123,12 +123,12 @@ describe('resumeTerminalVisibility reveal repaint', () => {
expect(resetTerminalLinkifierHoverState).toHaveBeenCalledWith(second)
})
it('schedules the repaint after rendering resumes on a heavy reveal', () => {
it('schedules the settled present after rendering resumes on a heavy reveal', () => {
const order: string[] = []
const manager = createManager(order)
resumeTerminalVisibility(resumeArgs(manager, false))
expect(order).toEqual(['resume-rendering', 'fit-reveal', 'reveal-repaint'])
expect(order).toEqual(['resume-rendering', 'fit-reveal', 'reveal-present'])
})
it('routes a heavy reveal through fitAllRevealedPanes, not the sync fit', () => {
@@ -182,9 +182,9 @@ describe('resumeTerminalVisibility reveal repaint', () => {
resumeTerminalVisibility(resumeArgs(manager, false))
expect(presentPaneViewport).toHaveBeenCalledWith(pane)
// The expensive registry-wide rebuild is still deferred to the settled frame.
// A healthy shared atlas is never cleared on an ordinary reveal.
expect(resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled()
expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1)
expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled()
})
it('does not fit on a light tab reveal', () => {
@@ -113,8 +113,8 @@ export function resumeTerminalVisibility({
for (const pane of manager.getPanes()) {
presentPaneViewport(pane)
}
// Rebuild once after attachment/measurement instead of paying a duplicate global atlas reset.
manager.scheduleRevealRepaint()
// Settled present covers attach/measure races; the shared atlas is still valid (renderers rebuild their model on page-layout changes), so re-rasterizing every glyph is waste.
manager.scheduleRevealPresent()
}
})
}
@@ -197,13 +197,12 @@ describe('useTerminalPaneGlobalEffects', () => {
'fit-reveal',
'intent:terminal-a',
'intent:terminal-b',
'reveal-repaint'
'reveal-present'
])
expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled()
vi.advanceTimersByTime(0)
expect(order.slice(-3)).toEqual(['reveal-repaint', 'reset-atlas', 'refresh'])
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1)
// A healthy shared atlas is not cleared on an ordinary worktree reveal.
expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled()
expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled()
expect(mocks.restoreScrollStateAfterLayout).not.toHaveBeenCalled()
expect(mocks.flushTerminalOutput).toHaveBeenNthCalledWith(1, terminalA, {
maxChars: 256 * 1024
@@ -572,7 +571,8 @@ describe('useTerminalPaneGlobalEffects', () => {
expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled()
expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled()
expect(manager.refreshAllPanes).not.toHaveBeenCalled()
expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1)
expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled()
expect(manager.scheduleRevealPresent).toHaveBeenCalled()
})
it('enforces scroll intent after hidden layout changes the viewport', () => {
@@ -116,6 +116,10 @@ export function useTerminalPaneTitleEffects(controller: TerminalPaneCloseControl
setPaneTitleOverlayRects(clearPaneTitleOverlayRects)
return
}
// Hidden overlays are display:none; keeping their last rects avoids a forced layout and a pane re-render on every workspace switch.
if (!isVisible && !shouldMeasureHiddenStartup) {
return
}
let frame: number | null = null
const scheduleSync = (): void => {
if (frame !== null) {
@@ -147,6 +151,7 @@ export function useTerminalPaneTitleEffects(controller: TerminalPaneCloseControl
paneTitles,
renamingPaneId,
sessionRestoredBannerPaneIds,
shouldMeasureHiddenStartup,
syncPaneTitleOverlayRects,
containerRef,
managerRef,
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type React from 'react'
import {
areSelectionsEqual,
@@ -85,13 +85,25 @@ export function useListMultiSelection<T>({
}
}, [selectedKeys.size, getScope])
// Why read through a ref: every plain click replaces the selection, and selection-keyed callbacks would re-render every memoized row.
const latestSelectionRef = useRef({
visibleKeys,
selectedKeys,
selectionAnchorKey,
selectedItems
})
useLayoutEffect(() => {
latestSelectionRef.current = { visibleKeys, selectedKeys, selectionAnchorKey, selectedItems }
})
const updateSelectionForGesture = useCallback(
(event: Pick<React.MouseEvent, 'metaKey' | 'ctrlKey' | 'shiftKey'>, item: T): boolean => {
const latest = latestSelectionRef.current
const intent = getSelectionIntent(event, navigator.userAgent.includes('Mac'))
const result = updateSelection({
visibleIds: visibleKeys,
previousSelectedIds: selectedKeys,
previousAnchorId: selectionAnchorKey,
visibleIds: latest.visibleKeys,
previousSelectedIds: latest.selectedKeys,
previousAnchorId: latest.selectionAnchorKey,
targetId: getKey(item),
intent
})
@@ -100,20 +112,21 @@ export function useListMultiSelection<T>({
// Plain click navigates; modifier gestures are selection-only so a batch can build without switching away.
return intent !== 'replace'
},
[getKey, visibleKeys, selectedKeys, selectionAnchorKey]
[getKey]
)
const selectForContextMenu = useCallback(
(item: T): readonly T[] => {
const latest = latestSelectionRef.current
const key = getKey(item)
if (selectedKeys.has(key) && selectedKeys.size > 1) {
return selectedItems
if (latest.selectedKeys.has(key) && latest.selectedKeys.size > 1) {
return latest.selectedItems
}
setSelectedKeys(new Set([key]))
setSelectionAnchorKey(key)
return [item]
},
[getKey, selectedKeys, selectedItems]
[getKey]
)
return {
+32 -2
View File
@@ -18,7 +18,10 @@ import trCatalogUrl from './locales/tr.json?url'
import zhCatalogUrl from './locales/zh.json?url'
import zhHantCatalogUrl from './locales/zh-Hant.json?url'
import { isPseudoLocalizationLocale, pseudoLocalizeString } from './pseudo-localization'
import { advanceTranslationResourceVersion } from './translation-resource-version'
import {
advanceTranslationResourceVersion,
getTranslationResourceVersion
} from './translation-resource-version'
import { DEFAULT_LOCALE, resolveUiLocale } from './supported-languages'
import type { SupportedUiLocale } from '../../../shared/ui-locale'
import { isPluginUiLanguage, type UiLanguage } from '../../../shared/ui-language'
@@ -110,11 +113,38 @@ const getPluralRule = pluralResolver.getRule.bind(pluralResolver)
pluralResolver.getRule = (code: string, options?: { ordinal?: boolean }) =>
getPluralRule(pluralLocaleByResourceLanguage.get(code) ?? code, options)
export function translate(key: string, fallback: string, options?: TOptions): string {
// Re-rendered chrome resolves hundreds of option-free strings per workspace switch; they only change with language or resources.
const PLAIN_TRANSLATION_CACHE_LIMIT = 5000
const plainTranslationCache = new Map<string, string>()
let plainTranslationCacheScope = ''
function translateUncached(key: string, fallback: string, options?: TOptions): string {
const value = i18n.t(resolvePluralFamilyKey(key, options), { defaultValue: fallback, ...options })
return isPseudoLocalizationLocale(i18n.language) ? pseudoLocalizeString(value) : value
}
export function translate(key: string, fallback: string, options?: TOptions): string {
if (options !== undefined) {
return translateUncached(key, fallback, options)
}
const scope = `${i18n.isInitialized}\0${i18n.language}\0${getTranslationResourceVersion()}`
if (
scope !== plainTranslationCacheScope ||
plainTranslationCache.size >= PLAIN_TRANSLATION_CACHE_LIMIT
) {
plainTranslationCache.clear()
plainTranslationCacheScope = scope
}
const cacheKey = `${key}\0${fallback}`
const cached = plainTranslationCache.get(cacheKey)
if (cached !== undefined) {
return cached
}
const value = translateUncached(key, fallback)
plainTranslationCache.set(cacheKey, value)
return value
}
export async function loadRendererUiLanguage(language: UiLanguage): Promise<string> {
const resolved = resolveUiLocale(language)
const resourceLanguage = resolveRendererResourceLanguage(resolved)
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../shared/constants'
import { useAppStore } from '@/store'
import { getWorktreeVisitTimestamp } from './worktree-visit-recency'
import { activateAndRevealWorktree } from './worktree-activation'
import { ensureWebRuntimeWorktreeTerminalAfterWake } from './web-runtime-worktree-terminal-after-wake'
import { resetWebSessionTabsSnapshotFreshnessForTests } from '@/runtime/web-session-tabs-sync/tracking-lifecycle'
@@ -54,28 +55,39 @@ afterEach(() => {
describe('activateAndRevealWorktree', () => {
it('does not restamp focus recency when reselecting the already-active terminal worktree', () => {
const worktree = makeWorktree()
const { markWorktreeVisited, recordWorktreeVisit, revealWorktreeInSidebar } =
seedAlreadyActiveWorktree(worktree)
const { revealWorktreeInSidebar } = seedAlreadyActiveWorktree(worktree)
const before = useAppStore.getState()
const result = activateAndRevealWorktree(worktree.id)
expect(result).toEqual({ primaryTabId: null })
expect(markWorktreeVisited).not.toHaveBeenCalled()
expect(recordWorktreeVisit).not.toHaveBeenCalled()
expect(useAppStore.getState().lastVisitedAtByWorktreeId).toBe(before.lastVisitedAtByWorktreeId)
expect(useAppStore.getState().worktreeNavHistory).toBe(before.worktreeNavHistory)
expect(revealWorktreeInSidebar).toHaveBeenCalledWith(worktree.id)
})
it('records a visit when activating the same worktree changes the current view', () => {
const worktree = makeWorktree()
const { markWorktreeVisited, recordWorktreeVisit } = seedAlreadyActiveWorktree(worktree, {
activeView: 'tasks'
})
seedAlreadyActiveWorktree(worktree, { activeView: 'tasks' })
const result = activateAndRevealWorktree(worktree.id)
expect(result).toEqual({ primaryTabId: null })
expect(markWorktreeVisited).toHaveBeenCalledWith(worktree.id)
expect(recordWorktreeVisit).toHaveBeenCalledWith(worktree.id)
const state = useAppStore.getState()
expect(getWorktreeVisitTimestamp(state.lastVisitedAtByWorktreeId, worktree)).toBeDefined()
expect(state.worktreeNavHistory.at(-1)).toBe(worktree.id)
})
it('keeps the fresh visit stamp when the history entry is de-duplicated', () => {
const worktree = makeWorktree()
seedAlreadyActiveWorktree(worktree, { activeView: 'tasks' })
useAppStore.setState({ worktreeNavHistory: [worktree.id], worktreeNavHistoryIndex: 0 })
activateAndRevealWorktree(worktree.id)
const state = useAppStore.getState()
expect(getWorktreeVisitTimestamp(state.lastVisitedAtByWorktreeId, worktree)).toBeDefined()
expect(state.worktreeNavHistory).toEqual([worktree.id])
})
it('adds the activated project to an active project filter', () => {
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Worktree } from '../../../shared/worktree/types'
import { useAppStore } from '@/store'
import { activateAndRevealWorktree } from './worktree-activation'
import { getWorktreeVisitKey } from './worktree-visit-recency'
vi.mock('../../../shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true }))
@@ -39,7 +40,6 @@ describe('switching workspaces with the sidebar-inputs switch on', () => {
vi.useFakeTimers({ now: 50_000, toFake: ['Date'] })
const shells = worktree('shells')
const next = worktree('next')
const markWorktreeVisited = vi.fn()
useAppStore.setState({
repos: [
{ id: 'repo-1', path: '/workspace/repo', displayName: 'repo', badgeColor: '', addedAt: 0 }
@@ -52,16 +52,15 @@ describe('switching workspaces with the sidebar-inputs switch on', () => {
tabsByWorktree: { [shells.id]: [], [next.id]: [] },
ptyIdsByTabId: {},
everActivatedWorktreeIds: new Set([shells.id, next.id]),
markWorktreeVisited,
recordWorktreeVisit: vi.fn(),
lastVisitedAtByWorktreeId: {},
refreshGitHubForWorktreeIfStale: vi.fn()
})
activateAndRevealWorktree(next.id)
expect(markWorktreeVisited.mock.calls).toEqual([
[shells.id, 49_999, 'local'],
[next.id, 50_000]
])
expect(useAppStore.getState().lastVisitedAtByWorktreeId).toEqual({
[getWorktreeVisitKey(shells.id, 'local')]: 49_999,
[next.id]: 50_000
})
})
})
@@ -1,6 +1,9 @@
import type { ExecutionHostId } from '../../../shared/execution-host'
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../shared/sidebar-inputs-switch'
import type { AppState } from '@/store/types'
import type { ActiveWorktreeStateTransition } from '@/store/slices/worktree-helpers'
import { stampWorktreeVisit } from '@/store/slices/worktrees/session/worktree-visit-recency'
import { appendHistoryEntry } from '@/store/slices/worktree-nav-history'
type PreviousActive = { worktreeId: string | null; hostId: ExecutionHostId | null }
type VisitState = Pick<
@@ -12,6 +15,33 @@ export function readPreviousActive(state: VisitState): PreviousActive {
return { worktreeId: state.activeWorktreeId, hostId: state.activeWorkspaceExecutionHostId }
}
/** Arguments of one markWorktreeVisited call. */
type VisitStamp = Parameters<AppState['markWorktreeVisited']>
/** The recency stamps opening `workspaceId` writes, in write order. */
export function visitStampsFor(
workspaceId: string,
previous: PreviousActive,
nextHostId?: ExecutionHostId | null
): VisitStamp[] {
if (!ORCA_SIDEBAR_INPUTS_IS_MAIN) {
return [[workspaceId]]
}
// Switch point: terminals no longer stamp Recent, so leaving a workspace marks it used up to now.
const visitedAt = Date.now()
// The same repoId::path on another host is another workspace.
const left =
previous.worktreeId !== null &&
(previous.worktreeId !== workspaceId ||
(nextHostId !== undefined && previous.hostId !== nextHostId))
const stamps: VisitStamp[] = []
if (left && previous.worktreeId) {
stamps.push([previous.worktreeId, visitedAt - 1, previous.hostId ?? undefined])
}
stamps.push([workspaceId, visitedAt])
return stamps
}
/** Stamps focus recency for the workspace being opened; `nextHostId` when the caller names one. */
export function markVisited(
state: VisitState,
@@ -19,19 +49,72 @@ export function markVisited(
previous: PreviousActive,
nextHostId?: ExecutionHostId | null
): void {
// Switch point: terminals no longer stamp Recent, so leaving a workspace marks it used up to now.
if (!ORCA_SIDEBAR_INPUTS_IS_MAIN) {
state.markWorktreeVisited(workspaceId)
return
for (const stamp of visitStampsFor(workspaceId, previous, nextHostId)) {
state.markWorktreeVisited(...stamp)
}
}
/** Recency stamps as markVisited writes them after activation: resolved against the post-activation selection. */
function stampActivationVisits(
current: AppState,
worktreeId: string,
previousActive: PreviousActive,
nextHostId: ExecutionHostId | null
): AppState['lastVisitedAtByWorktreeId'] {
const visitState = {
lastVisitedAtByWorktreeId: current.lastVisitedAtByWorktreeId,
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: nextHostId,
getKnownWorktreeById: current.getKnownWorktreeById
}
for (const stamp of visitStampsFor(worktreeId, previousActive, nextHostId)) {
visitState.lastVisitedAtByWorktreeId = stampWorktreeVisit(visitState, ...stamp)
}
return visitState.lastVisitedAtByWorktreeId
}
// A de-duplicated append returns the whole input state; spreading it would undo the fresh visit stamps.
function historyPatch({
worktreeNavHistory,
worktreeNavHistoryIndex
}: Pick<AppState, 'worktreeNavHistory' | 'worktreeNavHistoryIndex'>): Pick<
AppState,
'worktreeNavHistory' | 'worktreeNavHistoryIndex'
> {
return { worktreeNavHistory, worktreeNavHistoryIndex }
}
/** Folds the repo follow, recency stamps and nav-history entry into the activation write: each store write re-runs every subscriber's selector. */
export function buildActivationStateTransition(
state: VisitState & Pick<AppState, 'activeRepoId' | 'isNavigatingHistory'>,
worktreeId: string,
args: { repoId: string; stampsVisit: boolean; nextHostId: ExecutionHostId | null }
): { stateTransition: ActiveWorktreeStateTransition } | undefined {
const { stampsVisit, nextHostId } = args
const repoId = args.repoId === state.activeRepoId ? null : args.repoId
if (repoId === null && !stampsVisit) {
return undefined
}
const previousActive = readPreviousActive(state)
// History replay moves the index instead of visiting anew.
const recordsHistory = stampsVisit && !state.isNavigatingHistory
return {
stateTransition: (current) => ({
activate: true,
patch: {
...(repoId !== null ? { activeRepoId: repoId } : {}),
...(stampsVisit
? {
lastVisitedAtByWorktreeId: stampActivationVisits(
current,
worktreeId,
previousActive,
nextHostId
)
}
: {}),
...(recordsHistory ? historyPatch(appendHistoryEntry(current, worktreeId)) : {})
}
})
}
const visitedAt = Date.now()
// The same repoId::path on another host is another workspace.
const left =
previous.worktreeId !== null &&
(previous.worktreeId !== workspaceId ||
(nextHostId !== undefined && previous.hostId !== nextHostId))
if (left && previous.worktreeId) {
state.markWorktreeVisited(previous.worktreeId, visitedAt - 1, previous.hostId ?? undefined)
}
state.markWorktreeVisited(workspaceId, visitedAt)
}
+15 -17
View File
@@ -39,7 +39,11 @@ import { liftSidebarFiltersHidingWorktree } from './worktree-activation-sidebar-
import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree'
import { revealFloatingWorkspacePanel } from './floating-workspace-panel-reveal'
import { findPendingWorktreeCreationId } from './pending-worktree-creation'
import { markVisited, readPreviousActive } from './worktree-activation-visit'
import {
buildActivationStateTransition,
markVisited,
readPreviousActive
} from './worktree-activation-visit'
import { selectFolderWorkspaces } from '@/store/workspace-record-selectors'
/** Share palette/dialog activation; derive repoId and no-op if the selected worktree vanished. */
@@ -202,16 +206,20 @@ export function activateAndRevealWorktree(
state.activeWorkspaceExecutionHostId === (opts?.executionHostId ?? null) &&
state.activeView === 'terminal'
if (wt.repoId !== state.activeRepoId) {
state.setActiveRepo(wt.repoId)
}
if (state.activeView !== 'terminal') {
state.setActiveView('terminal')
}
const previousActive = readPreviousActive(state)
state.setActiveWorktree(worktreeId, opts?.executionHostId)
state.setActiveWorktree(
worktreeId,
opts?.executionHostId,
buildActivationStateTransition(state, worktreeId, {
repoId: wt.repoId,
// Why: Cmd+J focus recency (docs/cmd-j-empty-query-ordering.md); a plain reselect must not restamp it.
stampsVisit: !isPlainAlreadyActiveTerminal,
nextHostId: opts?.executionHostId ?? null
})
)
const postActivationState = useAppStore.getState()
const ownerRuntimeEnvironmentId = runtimeEnvironmentIdOrLocalUnlessTwin(
postActivationState,
@@ -225,16 +233,6 @@ export function activateAndRevealWorktree(
})
}
// Why: focus recency for Cmd+J ordering, distinct from recordWorktreeVisit/lastActivityAt; stamp before any later async step could throw. See docs/cmd-j-empty-query-ordering.md.
if (!isPlainAlreadyActiveTerminal) {
markVisited(state, worktreeId, previousActive, opts?.executionHostId ?? null)
}
// Why: skip re-recording for goBack/goForward history navigation — it moves the index instead of visiting anew (isNavigatingHistory).
if (!isPlainAlreadyActiveTerminal && !state.isNavigatingHistory) {
state.recordWorktreeVisit(worktreeId)
}
// Decide gate before resuming: resume consumes sleeping evidence, and late structured inventory can otherwise authorize a shell beside a pending chat (STA-1111).
const shouldGateAgentActivation =
!hasActivationWork &&
@@ -1,3 +1,4 @@
import { shallow } from 'zustand/shallow'
import type { EditorGet, EditorSet } from '../types/editor-set-get'
import type { EditorSlice } from '../types/editor-slice'
import { defaultFileSearchState } from '../search/file-search-state'
@@ -19,11 +20,16 @@ export function createFileSearchActions(
fileSearchStateByWorktree: {},
updateFileSearchState: (worktreeId, updates) =>
set((s) => {
const current = s.fileSearchStateByWorktree[worktreeId] || defaultFileSearchState()
const existing = s.fileSearchStateByWorktree[worktreeId]
const next = { ...(existing || defaultFileSearchState()), ...updates }
// Switching workspaces cancels the outgoing idle search; a no-op write would re-notify every subscriber.
if (existing && shallow(existing, next)) {
return s
}
return {
fileSearchStateByWorktree: {
...s.fileSearchStateByWorktree,
[worktreeId]: { ...current, ...updates }
[worktreeId]: next
}
}
}),
@@ -147,7 +147,7 @@ function isLiveEntry(entry: WorktreeNavHistoryEntry, state: AppState): boolean {
return findWorktreeById(selectWorktreesByRepo(state), entry) !== undefined
}
function appendHistoryEntry(
export function appendHistoryEntry(
s: { worktreeNavHistory: WorktreeNavHistoryEntry[]; worktreeNavHistoryIndex: number },
entry: WorktreeNavHistoryEntry
): { worktreeNavHistory: WorktreeNavHistoryEntry[]; worktreeNavHistoryIndex: number } {
@@ -1,5 +1,6 @@
import { runAsAutomaticDeselection } from '@/store/active-worktree-deselection-intent'
import type { WorktreeSlice } from '../../worktree-helpers'
import type { AppState } from '../../../types'
import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types'
import { getRepoIdFromWorktreeId } from '../../worktree-helpers'
import { worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope'
@@ -19,32 +20,55 @@ export function createMarkWorktreeVisited(
_get: WorktreeSliceGet
): WorktreeSlice['markWorktreeVisited'] {
return (worktreeId, visitedAt, executionHostId) => {
// Cmd+J needs user-focus recency independent of PTY activity; racing CLI/IPC timestamps must not regress (cmd-j-empty-query-ordering.md).
set((s) => {
const now = visitedAt ?? Date.now()
const ownerHostId =
executionHostId ??
(s.activeWorktreeId === worktreeId ? s.activeWorkspaceExecutionHostId : undefined) ??
s.getKnownWorktreeById(worktreeId, executionHostId)?.hostId
const visitKey = getWorktreeVisitKey(worktreeId, ownerHostId)
const prev =
getWorktreeVisitTimestamp(s.lastVisitedAtByWorktreeId, {
id: worktreeId,
hostId: ownerHostId
}) ?? 0
if (!(now > prev)) {
return s
}
return {
lastVisitedAtByWorktreeId: {
...s.lastVisitedAtByWorktreeId,
[visitKey]: now
}
}
const lastVisitedAtByWorktreeId = stampWorktreeVisit(
s,
worktreeId,
visitedAt,
executionHostId
)
return lastVisitedAtByWorktreeId === s.lastVisitedAtByWorktreeId
? s
: { lastVisitedAtByWorktreeId }
})
}
}
type WorktreeVisitStampState = Pick<
AppState,
| 'lastVisitedAtByWorktreeId'
| 'activeWorktreeId'
| 'activeWorkspaceExecutionHostId'
| 'getKnownWorktreeById'
>
/** Returns the visit map with `worktreeId` stamped, or the same map when the stamp would not advance. */
export function stampWorktreeVisit(
s: WorktreeVisitStampState,
worktreeId: string,
visitedAt?: number,
executionHostId?: ExecutionHostId
): AppState['lastVisitedAtByWorktreeId'] {
// Cmd+J needs user-focus recency independent of PTY activity; racing CLI/IPC timestamps must not regress (cmd-j-empty-query-ordering.md).
const now = visitedAt ?? Date.now()
const ownerHostId =
executionHostId ??
(s.activeWorktreeId === worktreeId ? s.activeWorkspaceExecutionHostId : undefined) ??
s.getKnownWorktreeById(worktreeId, executionHostId)?.hostId
const prev =
getWorktreeVisitTimestamp(s.lastVisitedAtByWorktreeId, {
id: worktreeId,
hostId: ownerHostId
}) ?? 0
if (!(now > prev)) {
return s.lastVisitedAtByWorktreeId
}
return {
...s.lastVisitedAtByWorktreeId,
[getWorktreeVisitKey(worktreeId, ownerHostId)]: now
}
}
export function createPruneLastVisitedTimestamps(
set: WorktreeSliceSet,
_get: WorktreeSliceGet