From 8db8905fac7bb8e01ff2ed8982d2daa020c7d355 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 28 Oct 2026 09:08:11 -0700 Subject: [PATCH] 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 --- config/focus-setting-read-baseline.txt | 3 +- .../src/app-shell/ActiveWorkspaceSyncGate.tsx | 17 +++ .../src/app-shell/AppBackgroundServices.tsx | 2 + .../src/app-shell/AppRootSurfaces.tsx | 11 +- .../src/app-shell/AppWorkspaceShell.tsx | 20 +++- .../src/app-shell/TitlebarMainStrip.tsx | 6 +- .../src/app-shell/use-app-chrome-layout.ts | 28 +++-- .../src/app-shell/use-app-shell-services.ts | 10 -- .../app-shell/use-floating-workspace-panel.ts | 48 ++++++-- .../src/app-shell/use-global-keybindings.ts | 12 +- .../use-onboarding-and-feature-tips.ts | 13 +- .../FloatingTerminalToggleButton.test.tsx | 6 + .../FloatingTerminalToggleButton.tsx | 3 + .../sidebar/worktree-list/rows/item-row.tsx | 8 +- .../rows/repo-header-project-actions.tsx | 14 ++- .../viewport/VirtualizedWorktreeViewport.tsx | 38 +++++- .../viewport/virtual-row-context.ts | 20 +--- .../tab-bar/use-tab-bar-runtime-model.ts | 7 +- .../terminal-pane/TerminalErrorToast.test.ts | 3 +- .../terminal-visibility-resume.test.ts | 8 +- .../terminal-visibility-resume.ts | 4 +- ...e-global-effects-visibility-resume.test.ts | 12 +- .../use-terminal-pane-title-effects.ts | 5 + .../src/hooks/use-list-multi-selection.ts | 29 +++-- src/renderer/src/i18n/i18n.ts | 34 +++++- .../worktree-activation-created-agent.test.ts | 30 +++-- .../worktree-activation-leave-visit.test.ts | 13 +- .../src/lib/worktree-activation-visit.ts | 111 +++++++++++++++--- src/renderer/src/lib/worktree-activation.ts | 32 +++-- .../editor/actions/file-search-actions.ts | 10 +- .../src/store/slices/worktree-nav-history.ts | 2 +- .../session/worktree-visit-recency.ts | 66 +++++++---- 32 files changed, 446 insertions(+), 179 deletions(-) create mode 100644 src/renderer/src/app-shell/ActiveWorkspaceSyncGate.tsx diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 5949a9a02fd..14ae5b5a3e2 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -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 diff --git a/src/renderer/src/app-shell/ActiveWorkspaceSyncGate.tsx b/src/renderer/src/app-shell/ActiveWorkspaceSyncGate.tsx new file mode 100644 index 00000000000..039416527e0 --- /dev/null +++ b/src/renderer/src/app-shell/ActiveWorkspaceSyncGate.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 +} diff --git a/src/renderer/src/app-shell/AppBackgroundServices.tsx b/src/renderer/src/app-shell/AppBackgroundServices.tsx index cd93a2784d1..9ec68978429 100644 --- a/src/renderer/src/app-shell/AppBackgroundServices.tsx +++ b/src/renderer/src/app-shell/AppBackgroundServices.tsx @@ -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 ( <> + {/* Keep permission notices available across app surfaces. */} {/* Keep agent-status retention subscriptions out of the App render tree. */} diff --git a/src/renderer/src/app-shell/AppRootSurfaces.tsx b/src/renderer/src/app-shell/AppRootSurfaces.tsx index fb2472dca6c..8c38acd7189 100644 --- a/src/renderer/src/app-shell/AppRootSurfaces.tsx +++ b/src/renderer/src/app-shell/AppRootSurfaces.tsx @@ -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: { ) -} +}) diff --git a/src/renderer/src/app-shell/AppWorkspaceShell.tsx b/src/renderer/src/app-shell/AppWorkspaceShell.tsx index 4be638694f1..e9633769d0f 100644 --- a/src/renderer/src/app-shell/AppWorkspaceShell.tsx +++ b/src/renderer/src/app-shell/AppWorkspaceShell.tsx @@ -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' ? : null} @@ -82,7 +85,9 @@ function ActivePage({ layout }: { layout: AppChromeLayout }): React.JSX.Element reserveCollapsedSidebarHeaderSpace={layout.leftTitlebarChromeLayout.isFloating} /> ) : null} - {activeView === 'terminal' && !activeWorktreeId && !creationLayoutActive ? : null} + {activeView === 'terminal' && !hasActiveWorktree && !creationLayoutActive ? ( + + ) : null} ) } @@ -98,6 +103,11 @@ export function AppWorkspaceShell(props: { const scrollOffsetRef = useRef(0) const scrollAnchorRef = useRef(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: { {floatingWorkspace.showToggleButton ? ( - floatingWorkspace.setOpenWithFocus((open) => !open)} + onToggle={toggleFloatingWorkspace} /> ) : null} diff --git a/src/renderer/src/app-shell/TitlebarMainStrip.tsx b/src/renderer/src/app-shell/TitlebarMainStrip.tsx index 8c0a1e35b03..df383cb0275 100644 --- a/src/renderer/src/app-shell/TitlebarMainStrip.tsx +++ b/src/renderer/src/app-shell/TitlebarMainStrip.tsx @@ -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 } }) ) } diff --git a/src/renderer/src/app-shell/use-app-chrome-layout.ts b/src/renderer/src/app-shell/use-app-chrome-layout.ts index 3a243b9496a..bbf58c9dee1 100644 --- a/src/renderer/src/app-shell/use-app-chrome-layout.ts +++ b/src/renderer/src/app-shell/use-app-chrome-layout.ts @@ -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, diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index abf84b435e7..f7f832ba43e 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -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() diff --git a/src/renderer/src/app-shell/use-floating-workspace-panel.ts b/src/renderer/src/app-shell/use-floating-workspace-panel.ts index 3c2af0f49bd..32246dc7a96 100644 --- a/src/renderer/src/app-shell/use-floating-workspace-panel.ts +++ b/src/renderer/src/app-shell/use-floating-workspace-panel.ts @@ -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 + ] + ) } diff --git a/src/renderer/src/app-shell/use-global-keybindings.ts b/src/renderer/src/app-shell/use-global-keybindings.ts index 47092691906..d237ce6f508 100644 --- a/src/renderer/src/app-shell/use-global-keybindings.ts +++ b/src/renderer/src/app-shell/use-global-keybindings.ts @@ -47,9 +47,8 @@ export function useGlobalKeybindings(args: { const terminalShortcutPolicy = useAppStore((s) => s.settings?.terminalShortcutPolicy) const pluginCommands = usePluginCommands() - const shortcutState: AppShortcutState = { + const shortcutState: Omit = { 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, diff --git a/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts b/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts index 7129003a479..59383d7eca5 100644 --- a/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts +++ b/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts @@ -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] + ) } diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.test.tsx index fde5230a636..f273d760a4c 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.test.tsx @@ -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()), + useTranslation: () => ({}) +})) + vi.mock('lucide-react', () => ({ PanelsTopLeft: function PanelsTopLeft() { return null diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx index 5ddbc381516..84e3653743e 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx @@ -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) diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx index d3e6425886d..655e6751dc2 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx @@ -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} diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/repo-header-project-actions.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/repo-header-project-actions.tsx index 54c60961ed8..86b80453586 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/repo-header-project-actions.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/repo-header-project-actions.tsx @@ -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 ( @@ -184,9 +188,9 @@ export function RepoHeaderProjectActionsMenu({ ) -} +}) -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({ ) -} +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx b/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx index d3dfbad8270..e458b25d6b7 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx @@ -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 = [] @@ -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( + () => ({ + 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, diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts index addfc2c9457..53db5792eff 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts @@ -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 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, diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts index 4251a8bd4f3..0cefa057b60 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts index 8c501cc5fc8..e8542c55b6e 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts index 17b3d36ba9d..fc48fe0a0ae 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index 5181c48ea2d..b8a7a96f7fe 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -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() } }) } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts index 9fdf99ba554..93ce27d5e61 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-title-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-title-effects.ts index 8ff36fcfb52..ff011c95c74 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-title-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-title-effects.ts @@ -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, diff --git a/src/renderer/src/hooks/use-list-multi-selection.ts b/src/renderer/src/hooks/use-list-multi-selection.ts index 97efeb49f3d..ad5fadb1bc2 100644 --- a/src/renderer/src/hooks/use-list-multi-selection.ts +++ b/src/renderer/src/hooks/use-list-multi-selection.ts @@ -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({ } }, [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, 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({ // 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 { diff --git a/src/renderer/src/i18n/i18n.ts b/src/renderer/src/i18n/i18n.ts index 3d470bdcd2c..c5dc82298df 100644 --- a/src/renderer/src/i18n/i18n.ts +++ b/src/renderer/src/i18n/i18n.ts @@ -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() +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 { const resolved = resolveUiLocale(language) const resourceLanguage = resolveRendererResourceLanguage(resolved) diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index 51116245e7e..a9055d4eea2 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -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', () => { diff --git a/src/renderer/src/lib/worktree-activation-leave-visit.test.ts b/src/renderer/src/lib/worktree-activation-leave-visit.test.ts index 71019f28e97..851a1bc2f7d 100644 --- a/src/renderer/src/lib/worktree-activation-leave-visit.test.ts +++ b/src/renderer/src/lib/worktree-activation-leave-visit.test.ts @@ -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 + }) }) }) diff --git a/src/renderer/src/lib/worktree-activation-visit.ts b/src/renderer/src/lib/worktree-activation-visit.ts index 6b24f08173d..8a3675e5844 100644 --- a/src/renderer/src/lib/worktree-activation-visit.ts +++ b/src/renderer/src/lib/worktree-activation-visit.ts @@ -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 + +/** 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): 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, + 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) } diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 2e0fa74565f..36b0dcfd6aa 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -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 && diff --git a/src/renderer/src/store/slices/editor/actions/file-search-actions.ts b/src/renderer/src/store/slices/editor/actions/file-search-actions.ts index 4e8ef519ceb..a3ad6d876a1 100644 --- a/src/renderer/src/store/slices/editor/actions/file-search-actions.ts +++ b/src/renderer/src/store/slices/editor/actions/file-search-actions.ts @@ -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 } } }), diff --git a/src/renderer/src/store/slices/worktree-nav-history.ts b/src/renderer/src/store/slices/worktree-nav-history.ts index 1511d3bbc80..bd6fa428ad3 100644 --- a/src/renderer/src/store/slices/worktree-nav-history.ts +++ b/src/renderer/src/store/slices/worktree-nav-history.ts @@ -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 } { diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts b/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts index 937967e9805..ef23edc9f99 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts @@ -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