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