('input')
+ if (!input) {
+ throw new Error('worktree path input not found')
+ }
+ return input
+}
+
+function setNativeValue(input: HTMLInputElement, text: string): void {
+ // Why: React reads controlled-input changes via the native value setter;
+ // assigning input.value directly is swallowed by React's value tracking.
+ const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ setValue?.call(input, text)
+}
+
+function typeText(input: HTMLInputElement, text: string): void {
+ act(() => {
+ setNativeValue(input, text)
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+}
+
+function blurInput(input: HTMLInputElement): void {
+ // Why: React delegates onBlur via focusout (which bubbles) not blur (which
+ // doesn't), so dispatching focusout is required to trigger the React handler.
+ act(() => {
+ input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
+ })
+}
+
+describe('RepositoryWorktreeDefaultsSection — worktree path', () => {
+ it('does not call updateRepo while the user is typing', () => {
+ const updateRepo = vi.fn()
+ render(BASE_REPO, updateRepo)
+
+ const input = getWorktreePathInput()
+ typeText(input, './w')
+ typeText(input, './wo')
+ typeText(input, './wor')
+ typeText(input, './worktree')
+
+ expect(updateRepo).not.toHaveBeenCalled()
+ })
+
+ it('calls updateRepo with the final value on blur', () => {
+ const updateRepo = vi.fn()
+ render(BASE_REPO, updateRepo)
+
+ const input = getWorktreePathInput()
+ typeText(input, ' ./worktree ')
+ blurInput(input)
+
+ expect(updateRepo).toHaveBeenCalledTimes(1)
+ expect(updateRepo).toHaveBeenCalledWith('repo-1', { worktreeBasePath: './worktree' })
+ })
+
+ it('does not call updateRepo when the normalized value is unchanged on blur', () => {
+ const updateRepo = vi.fn()
+ render({ ...BASE_REPO, worktreeBasePath: './worktree' }, updateRepo)
+
+ const input = getWorktreePathInput()
+ typeText(input, ' ./worktree ')
+ blurInput(input)
+
+ expect(updateRepo).not.toHaveBeenCalled()
+ })
+
+ it('calls updateRepo with undefined when the field is cleared', () => {
+ const updateRepo = vi.fn()
+ render({ ...BASE_REPO, worktreeBasePath: '../worktrees' }, updateRepo)
+
+ const input = getWorktreePathInput()
+ typeText(input, '')
+ blurInput(input)
+
+ expect(updateRepo).toHaveBeenCalledWith('repo-1', { worktreeBasePath: undefined })
+ })
+
+ it('calls updateRepo with undefined when the value is whitespace-only', () => {
+ const updateRepo = vi.fn()
+ render({ ...BASE_REPO, worktreeBasePath: '../worktrees' }, updateRepo)
+
+ const input = getWorktreePathInput()
+ typeText(input, ' ')
+ blurInput(input)
+
+ expect(updateRepo).toHaveBeenCalledWith('repo-1', { worktreeBasePath: undefined })
+ })
+})
diff --git a/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx b/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx
index ab13d3cfb7a..db262bd0645 100644
--- a/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx
+++ b/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx
@@ -83,9 +83,16 @@ export function RepositoryWorktreeDefaultsSection({
repoId={repo.id}
storeValue={repo.worktreeBasePath ?? ''}
placeholder={settings?.workspaceDir ?? ''}
- onTextChange={(text) =>
- updateRepo(repo.id, { worktreeBasePath: text.trim() ? text : undefined })
- }
+ onTextChange={() => {}}
+ onBlur={(e) => {
+ const worktreeBasePath = e.currentTarget.value.trim() || undefined
+ // Why: even an unchanged worktreeBasePath update asks main to
+ // prepare the root, which can touch the filesystem.
+ if (worktreeBasePath === (repo.worktreeBasePath?.trim() || undefined)) {
+ return
+ }
+ updateRepo(repo.id, { worktreeBasePath })
+ }}
className="h-9 text-sm"
/>
diff --git a/src/renderer/src/components/sidebar/CacheTimer.test.tsx b/src/renderer/src/components/sidebar/CacheTimer.test.tsx
new file mode 100644
index 00000000000..651de8e0b82
--- /dev/null
+++ b/src/renderer/src/components/sidebar/CacheTimer.test.tsx
@@ -0,0 +1,90 @@
+import { renderToStaticMarkup } from 'react-dom/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { usePromptCacheCountdownStartedAt } from './CacheTimer'
+
+const scanMocks = vi.hoisted(() => ({
+ getMostUrgentPromptCacheStartedAt: vi.fn(() => 1_000)
+}))
+
+type MockState = {
+ cacheTimerByKey: Record
+ settings?: {
+ promptCacheTimerEnabled?: boolean
+ promptCacheTtlMs?: number
+ }
+ tabsByWorktree: Record
+}
+
+let mockState: MockState
+
+vi.mock('@/store', () => ({
+ useAppStore: (selector: (state: MockState) => unknown) => selector(mockState)
+}))
+
+vi.mock('./prompt-cache-timer-selection', () => ({
+ getMostUrgentPromptCacheStartedAt: scanMocks.getMostUrgentPromptCacheStartedAt,
+ getPromptCacheCountdownForPane: vi.fn(() => null)
+}))
+
+function AggregateTimerProbe({ active = true }: { active?: boolean }): React.JSX.Element {
+ const startedAt = usePromptCacheCountdownStartedAt('wt-1', active)
+ return {startedAt ?? 'none'}
+}
+
+describe('usePromptCacheCountdownStartedAt', () => {
+ beforeEach(() => {
+ scanMocks.getMostUrgentPromptCacheStartedAt.mockClear()
+ mockState = {
+ cacheTimerByKey: { 'tab-1:11111111-1111-4111-8111-111111111111': 1_000 },
+ settings: {
+ promptCacheTimerEnabled: true,
+ promptCacheTtlMs: 60_000
+ },
+ tabsByWorktree: {
+ 'wt-1': [{ id: 'tab-1' }]
+ }
+ }
+ })
+
+ it('does not scan aggregate cache timers while inactive', () => {
+ const markup = renderToStaticMarkup()
+
+ expect(markup).toContain('none')
+ expect(scanMocks.getMostUrgentPromptCacheStartedAt).not.toHaveBeenCalled()
+ })
+
+ it('does not scan aggregate cache timers while disabled', () => {
+ mockState.settings = {
+ promptCacheTimerEnabled: false,
+ promptCacheTtlMs: 60_000
+ }
+
+ const markup = renderToStaticMarkup()
+
+ expect(markup).toContain('none')
+ expect(scanMocks.getMostUrgentPromptCacheStartedAt).not.toHaveBeenCalled()
+ })
+
+ it('does not scan aggregate cache timers when ttl is zero', () => {
+ mockState.settings = {
+ promptCacheTimerEnabled: true,
+ promptCacheTtlMs: 0
+ }
+
+ const markup = renderToStaticMarkup()
+
+ expect(markup).toContain('none')
+ expect(scanMocks.getMostUrgentPromptCacheStartedAt).not.toHaveBeenCalled()
+ })
+
+ it('scans aggregate cache timers only when the timer can render', () => {
+ const markup = renderToStaticMarkup()
+
+ expect(markup).toContain('1000')
+ expect(scanMocks.getMostUrgentPromptCacheStartedAt).toHaveBeenCalledTimes(1)
+ expect(scanMocks.getMostUrgentPromptCacheStartedAt).toHaveBeenCalledWith(
+ mockState.tabsByWorktree['wt-1'],
+ mockState.cacheTimerByKey
+ )
+ })
+})
diff --git a/src/renderer/src/components/sidebar/CacheTimer.tsx b/src/renderer/src/components/sidebar/CacheTimer.tsx
index 8be4cb07d21..9932d97a8ec 100644
--- a/src/renderer/src/components/sidebar/CacheTimer.tsx
+++ b/src/renderer/src/components/sidebar/CacheTimer.tsx
@@ -2,8 +2,13 @@ import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import { Timer } from 'lucide-react'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
+import { useShallow } from 'zustand/react/shallow'
import { usePromptCacheCountdownNow } from './prompt-cache-countdown-clock'
-import { getMostUrgentPromptCacheStartedAt } from './prompt-cache-timer-selection'
+import {
+ getMostUrgentPromptCacheStartedAt,
+ getPromptCacheCountdownForPane,
+ type PromptCacheCountdownSelection
+} from './prompt-cache-timer-selection'
/**
* The most-urgent cache start time when a countdown should show, else null.
@@ -14,15 +19,45 @@ import { getMostUrgentPromptCacheStartedAt } from './prompt-cache-timer-selectio
* (shortest remaining) start time — if any tab's cache is about to expire, the
* user should know.
*/
-export function usePromptCacheCountdownStartedAt(worktreeId: string): number | null {
- const enabled = useAppStore((s) => s.settings?.promptCacheTimerEnabled ?? false)
- const ttlMs = useAppStore((s) => s.settings?.promptCacheTtlMs ?? 0)
- const startedAt = useAppStore((s) =>
- getMostUrgentPromptCacheStartedAt(s.tabsByWorktree[worktreeId], s.cacheTimerByKey)
+export function usePromptCacheCountdownStartedAt(worktreeId: string, active = true): number | null {
+ const [enabled, ttlMs, startedAt] = useAppStore(
+ useShallow((s) => {
+ if (!active) {
+ return [false, 0, null] as const
+ }
+ const enabled = s.settings?.promptCacheTimerEnabled ?? false
+ const ttlMs = s.settings?.promptCacheTtlMs ?? 0
+ if (!enabled || ttlMs <= 0) {
+ return [enabled, ttlMs, null] as const
+ }
+ return [
+ enabled,
+ ttlMs,
+ getMostUrgentPromptCacheStartedAt(s.tabsByWorktree[worktreeId], s.cacheTimerByKey)
+ ] as const
+ })
)
return enabled && ttlMs > 0 && startedAt != null ? startedAt : null
}
+export function usePromptCacheCountdownForPane(
+ paneKey: string,
+ active = true
+): PromptCacheCountdownSelection | null {
+ return useAppStore(
+ useShallow((s) => {
+ if (!active || !(s.settings?.promptCacheTimerEnabled ?? false)) {
+ return null
+ }
+ return getPromptCacheCountdownForPane(
+ paneKey,
+ s.cacheTimerByKey,
+ s.settings?.promptCacheTtlMs ?? 0
+ )
+ })
+ )
+}
+
/**
* Per-worktree prompt-cache countdown, shown in the sidebar worktree card. The
* card renders this only once a cache is active, so it's a pure countdown view.
diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx
index e09257ea01f..2a7e5bfbc36 100644
--- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx
+++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx
@@ -115,6 +115,10 @@ function buttonText(props: Record): string {
return renderToStaticMarkup(<>{props.children as ReactNode}>)
}
+function visibleMarkupText(markup: string): string {
+ return markup.replace(/<[^>]*>/g, '')
+}
+
describe('DeleteWorktreeDialog lineage copy', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -212,6 +216,20 @@ describe('DeleteWorktreeDialog lineage copy', () => {
expect(markup).not.toContain('including uncommitted or untracked files')
})
+ it('keeps a space between remove copy and the workspace name', async () => {
+ const workspace = makeWorktree('Hide working dot', '/workspaces/hide-working-dot')
+ mocks.state.modalData = { worktreeId: workspace.id }
+ mocks.state.allWorktrees.mockReturnValue([workspace])
+
+ const { default: DeleteWorktreeDialog } = await import('./DeleteWorktreeDialog')
+ const markup = renderToStaticMarkup()
+
+ expect(visibleMarkupText(markup)).toContain(
+ 'Remove Hide working dot from git and delete its workspace folder.'
+ )
+ expect(markup).not.toContain('RemoveHide working dot')
+ })
+
it('shows an inline warning when the workspace has uncommitted or untracked changes', async () => {
const workspace = makeWorktree('Feature workspace', '/workspaces/feature')
mocks.state.modalData = { worktreeId: workspace.id }
diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx
index f24c0a5ac17..cb4c9f3da58 100644
--- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx
+++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx
@@ -12,6 +12,7 @@ import { getConnectionId } from '@/lib/connection-context'
import { getRuntimeGitStatus } from '@/runtime/runtime-git-client'
import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner'
import { runWorktreeDeletesInParallel } from './delete-worktree-flow'
+import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-after-delete'
import { getWorkspaceDeleteLineage } from './workspace-delete-lineage'
import { DeleteWorktreeLineageNotice } from './DeleteWorktreeLineageNotice'
import { DeleteWorktreeSkipConfirmOption } from './DeleteWorktreeSkipConfirmOption'
@@ -270,6 +271,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
// inside the dialog — it runs the destructive retry directly without
// the shared toast wrapper. Close immediately because workspace cards
// already show the deleting state while the retry runs.
+ const commitFocus = prepareActiveWorktreeFocusAfterDelete(worktreeId)
const deletePromise = removeWorktree(worktreeId, true)
closeModal()
deletePromise
@@ -286,6 +288,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
)
return
}
+ commitFocus()
onDeleted?.([worktreeId])
})
.catch((err: unknown) => {
diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialogDescription.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialogDescription.tsx
index 0d960e16844..60beeda6759 100644
--- a/src/renderer/src/components/sidebar/DeleteWorktreeDialogDescription.tsx
+++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialogDescription.tsx
@@ -16,7 +16,7 @@ export function DeleteWorktreeDialogDescription({
}): React.JSX.Element {
return (
- {translate('auto.components.sidebar.DeleteWorktreeDialog.91492c9ad6', 'Remove')}
+ {translate('auto.components.sidebar.DeleteWorktreeDialog.91492c9ad6', 'Remove')}{' '}
{targetLabel}
{canDeleteAllLineage ? (
<>
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx
index b37e5e671aa..d46fc517284 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx
@@ -1,6 +1,7 @@
import { renderToStaticMarkup } from 'react-dom/server'
import React, { type ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { GlobalSettings, Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types'
import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports'
@@ -14,17 +15,23 @@ const updateWorktreeMeta = vi.fn()
const recordFeatureInteraction = vi.fn()
const setWorkspacePortScan = vi.fn()
const setWorkspacePortScanRefreshing = vi.fn()
+const cacheTimerMocks = vi.hoisted(() => ({
+ usePromptCacheCountdownStartedAt: vi.fn()
+}))
let worktreeCardProperties: WorktreeCardProperty[] = ['status', 'ports']
let hostedReviewCache: Record = {}
let projectGroups: unknown[] = []
let workspacePortScan: { key: string; result: WorkspacePortScanResult } | null = null
let settings: Partial | null = { compactWorktreeCards: true }
+let agentActivityDisplayMode: 'compact' | 'full' | undefined
+let mockInlineAgentRows: DashboardAgentRowData[] = []
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({
browserTabsByWorktree: {},
+ agentActivityDisplayMode,
createBrowserTab: vi.fn(),
deleteStateByWorktreeId: {},
fetchHostedReviewForBranch,
@@ -90,12 +97,16 @@ vi.mock('./use-worktree-activity-status', () => ({
vi.mock('./CacheTimer', () => ({
default: () => null,
- usePromptCacheCountdownStartedAt: () => null
+ usePromptCacheCountdownStartedAt: cacheTimerMocks.usePromptCacheCountdownStartedAt
+}))
+
+vi.mock('./useWorktreeAgentRows', () => ({
+ useWorktreeAgentRows: vi.fn(() => mockInlineAgentRows)
}))
vi.mock('./WorktreeCardAgents', () => ({
- default: ({ className }: { className?: string }) => (
-
+ default: ({ className, agents }: { className?: string; agents?: DashboardAgentRowData[] }) => (
+
)
}))
@@ -177,6 +188,9 @@ describe('WorktreeCard compact hover details', () => {
projectGroups = []
workspacePortScan = null
settings = { compactWorktreeCards: true }
+ agentActivityDisplayMode = undefined
+ mockInlineAgentRows = []
+ cacheTimerMocks.usePromptCacheCountdownStartedAt.mockReturnValue(null)
})
it('shows PR and live port details from the compact worktree card hover', async () => {
@@ -519,6 +533,65 @@ describe('WorktreeCard compact hover details', () => {
expect(markup).not.toContain('data-worktree-agents')
})
+ it('does not create a compact metadata row solely for an aggregate cache timer', async () => {
+ settings = { compactWorktreeCards: true }
+ worktreeCardProperties = ['status']
+ cacheTimerMocks.usePromptCacheCountdownStartedAt.mockImplementation(
+ (_worktreeId: string, active = true) => (active ? 10_000 : null)
+ )
+ const worktree = makeWorktree()
+ const { default: WorktreeCard } = await import('./WorktreeCard')
+
+ const markup = renderToStaticMarkup(
+
+ )
+
+ expect(cacheTimerMocks.usePromptCacheCountdownStartedAt).toHaveBeenCalledWith(
+ worktree.id,
+ false
+ )
+ expect(markup).not.toContain('data-worktree-card-meta-row=""')
+ })
+
+ it('suppresses the aggregate cache timer when compact inline agents are visible', async () => {
+ settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
+ worktreeCardProperties = ['status', 'inline-agents']
+ agentActivityDisplayMode = 'compact'
+ mockInlineAgentRows = [{} as DashboardAgentRowData]
+ const worktree = makeWorktree()
+ const { default: WorktreeCard } = await import('./WorktreeCard')
+
+ const markup = renderToStaticMarkup(
+
+ )
+
+ expect(markup).toContain('data-worktree-agents=""')
+ expect(cacheTimerMocks.usePromptCacheCountdownStartedAt).toHaveBeenCalledWith(
+ worktree.id,
+ false
+ )
+ })
+
+ it('preserves the aggregate cache timer when compact inline agents are enabled but absent', async () => {
+ settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
+ worktreeCardProperties = ['status', 'inline-agents']
+ agentActivityDisplayMode = 'compact'
+ mockInlineAgentRows = []
+ cacheTimerMocks.usePromptCacheCountdownStartedAt.mockImplementation(
+ (_worktreeId: string, active = true) => (active ? 10_000 : null)
+ )
+ const worktree = makeWorktree()
+ const { default: WorktreeCard } = await import('./WorktreeCard')
+
+ const markup = renderToStaticMarkup(
+
+ )
+
+ expect(markup).toContain('data-worktree-agents=""')
+ expect(markup).toContain('data-agent-count="0"')
+ expect(cacheTimerMocks.usePromptCacheCountdownStartedAt).toHaveBeenCalledWith(worktree.id, true)
+ })
+
it('keeps child card markup outside the parent hover trigger when new card style is on', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'comment']
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx
index 386ee7dcda5..07a0b887fb2 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx
@@ -23,6 +23,7 @@ import WorktreeContextMenu from './WorktreeContextMenu'
import { SshDisconnectedDialog } from './SshDisconnectedDialog'
import { AutoRenameFailedDialog } from './AutoRenameFailedDialog'
import WorktreeCardAgents from './WorktreeCardAgents'
+import { useWorktreeAgentRows } from './useWorktreeAgentRows'
import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot'
import { cn } from '@/lib/utils'
import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation'
@@ -75,6 +76,7 @@ import { translate } from '@/i18n/i18n'
import { recordRendererCrashBreadcrumb } from '@/lib/crash-diagnostics'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { parseExecutionHostId } from '../../../../shared/execution-host'
+import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constants'
type WorktreeRenameRequest = {
worktreeId: string
@@ -231,6 +233,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
const fetchIssue = useAppStore((s) => s.fetchIssue)
const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue)
const cardProps = useAppStore((s) => s.worktreeCardProperties)
+ const agentActivityDisplayMode =
+ useAppStore((s) => s.agentActivityDisplayMode) ?? DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE
const projectGroups = useAppStore((s) => s.projectGroups)
const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true
const compactCards = !newCardStyle && settings?.compactWorktreeCards === true
@@ -959,6 +963,16 @@ const WorktreeCard = React.memo(function WorktreeCard({
const metaReview = showPR ? hoverReview : null
const metaAutomationProvenance = showAutomation ? worktree.automationProvenance : null
const metaComment = showComment ? hoverComment : null
+ const showInlineAgentList = cardProps.includes('inline-agents') && (newCardStyle || !compactCards)
+ const compactInlineAgentRows = useWorktreeAgentRows(
+ worktree.id,
+ showInlineAgentList && agentActivityDisplayMode === 'compact'
+ )
+ const compactInlineAgentRowsVisible =
+ showInlineAgentList &&
+ agentActivityDisplayMode === 'compact' &&
+ compactInlineAgentRows.length > 0
+ const showAggregateCacheTimer = !compactCards && !compactInlineAgentRowsVisible
const handleOpenGitHubIssueInOrca = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
@@ -1051,8 +1065,10 @@ const WorktreeCard = React.memo(function WorktreeCard({
automationProvenance: metaAutomationProvenance
})
const hasPorts = showPorts && workspacePorts.length > 0
- const cacheStartedAt = usePromptCacheCountdownStartedAt(worktree.id)
- const cacheTtlMs = useAppStore((s) => s.settings?.promptCacheTtlMs ?? 0)
+ const cacheStartedAt = usePromptCacheCountdownStartedAt(worktree.id, showAggregateCacheTimer)
+ const cacheTtlMs = useAppStore((s) =>
+ showAggregateCacheTimer ? (s.settings?.promptCacheTtlMs ?? 0) : 0
+ )
// Why: pinned trees mix repos in one section; a leading repo icon keeps the
// list scannable, so it shows regardless of groupBy's hideRepoBadge.
const showPinnedRepoIcon = inPinnedSection && !!repo
@@ -1118,7 +1134,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
? trimmedVisibleCardTitle
: undefined
const hasHoverIdentity = Boolean(hoverWorkspaceTitle || hoverBranchName)
- const showInlineAgentList = cardProps.includes('inline-agents') && (newCardStyle || !compactCards)
const hasHoverDetails =
newCardStyle &&
(hasWorktreeCardDetails({
@@ -1626,6 +1641,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
{showInlineAgentList && (
)}
diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx
index dd31a6cdc68..f3bd592f3e2 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx
@@ -4,6 +4,10 @@ import { renderToStaticMarkup } from 'react-dom/server'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
+import { makePaneKey } from '../../../../shared/stable-pane-id'
+
+const LEAF_A = '11111111-1111-4111-8111-111111111111'
+const LEAF_B = '22222222-2222-4222-8222-222222222222'
type MockAgentOptions = {
paneKey?: string
@@ -66,6 +70,9 @@ function mockAgent({
let mockAgents: unknown[] = [mockAgent()]
let mockFocusedAgentPaneKey: string | null = null
let mockAgentActivityDisplayMode: 'compact' | 'full' | undefined
+let mockPromptCacheTimerEnabled = true
+let mockPromptCacheTtlMs = 60_000
+let mockCacheTimerByKey: Record = {}
let capturedRowActivations: {
paneKey: string
onActivate: (tabId: string, paneKey: string) => void
@@ -81,6 +88,7 @@ vi.mock('@/store', () => ({
selector({
agentActivityDisplayMode: mockAgentActivityDisplayMode,
acknowledgedAgentsByPaneKey: {},
+ cacheTimerByKey: mockCacheTimerByKey,
dropAgentStatus: vi.fn(),
dismissRetainedAgent: vi.fn(),
acknowledgeAgents: vi.fn(),
@@ -88,7 +96,11 @@ vi.mock('@/store', () => ({
agentStatusByPaneKey: {},
tabsByWorktree: {},
terminalLayoutsByTabId: {},
- sendPromptToSidebarAgentTarget: vi.fn()
+ sendPromptToSidebarAgentTarget: vi.fn(),
+ settings: {
+ promptCacheTimerEnabled: mockPromptCacheTimerEnabled,
+ promptCacheTtlMs: mockPromptCacheTtlMs
+ }
})
}))
@@ -108,6 +120,10 @@ vi.mock('@/components/dashboard/useNow', () => ({
useNow: vi.fn(() => 2000)
}))
+vi.mock('./prompt-cache-countdown-clock', () => ({
+ usePromptCacheCountdownNow: vi.fn(() => 10_000)
+}))
+
vi.mock('@/components/dashboard/DashboardAgentRow', () => ({
default: ({
agent,
@@ -177,6 +193,9 @@ describe('WorktreeCardAgents', () => {
mockAgents = [mockAgent()]
mockFocusedAgentPaneKey = null
mockAgentActivityDisplayMode = undefined
+ mockPromptCacheTimerEnabled = true
+ mockPromptCacheTtlMs = 60_000
+ mockCacheTimerByKey = {}
capturedRowActivations = []
})
@@ -246,6 +265,100 @@ describe('WorktreeCardAgents', () => {
expect(markup).not.toContain('Focused prompt')
})
+ it('shows a matching pane prompt-cache timer before the compact row age', async () => {
+ mockAgentActivityDisplayMode = 'compact'
+ const paneKey = makePaneKey('tab-1', LEAF_A)
+ mockAgents = [
+ mockAgent({
+ paneKey,
+ tabId: 'tab-1',
+ agentType: 'claude',
+ startedAt: 1000,
+ prompt: 'Resume Claude'
+ })
+ ]
+ mockCacheTimerByKey = { [paneKey]: 10_000 }
+ const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents')
+
+ const markup = renderToStaticMarkup()
+ const timerIndex = markup.indexOf('Prompt cache expires in 1:00')
+ const ageIndex = markup.indexOf('>now')
+
+ expect(timerIndex).toBeGreaterThanOrEqual(0)
+ expect(ageIndex).toBeGreaterThanOrEqual(0)
+ expect(timerIndex).toBeLessThan(ageIndex)
+ })
+
+ it('does not show a prompt-cache timer on a nonmatching compact row', async () => {
+ mockAgentActivityDisplayMode = 'compact'
+ const paneKey = makePaneKey('tab-1', LEAF_A)
+ const otherPaneKey = makePaneKey('tab-1', LEAF_B)
+ mockAgents = [
+ mockAgent({
+ paneKey,
+ tabId: 'tab-1',
+ agentType: 'claude',
+ startedAt: 1000,
+ prompt: 'No timer here'
+ })
+ ]
+ mockCacheTimerByKey = { [otherPaneKey]: 10_000 }
+ const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents')
+
+ const markup = renderToStaticMarkup()
+
+ expect(markup).toContain('No timer here')
+ expect(markup).not.toContain('Prompt cache expires')
+ })
+
+ it('does not show a prompt-cache timer when the feature is disabled', async () => {
+ mockAgentActivityDisplayMode = 'compact'
+ mockPromptCacheTimerEnabled = false
+ const paneKey = makePaneKey('tab-1', LEAF_A)
+ mockAgents = [
+ mockAgent({
+ paneKey,
+ tabId: 'tab-1',
+ agentType: 'claude',
+ startedAt: 1000,
+ prompt: 'Disabled timer'
+ })
+ ]
+ mockCacheTimerByKey = { [paneKey]: 10_000 }
+ const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents')
+
+ const markup = renderToStaticMarkup()
+
+ expect(markup).toContain('Disabled timer')
+ expect(markup).not.toContain('Prompt cache expires')
+ })
+
+ it('keeps hidden retained compact rows from rendering prompt-cache timers', async () => {
+ const paneKey = makePaneKey('tab-1', LEAF_A)
+ mockCacheTimerByKey = { [paneKey]: 10_000 }
+ const { CompactAgentRow } = await import('./worktree-card-compact-agents')
+
+ const markup = renderToStaticMarkup(
+
+ )
+
+ expect(markup).toContain('Collapsed child')
+ expect(markup).not.toContain('Prompt cache expires')
+ })
+
it('marks only the focused agent row', async () => {
mockAgentActivityDisplayMode = 'full'
mockFocusedAgentPaneKey = 'tab-1:2'
@@ -431,6 +544,37 @@ describe('WorktreeCardAgents', () => {
expect(markup).not.toContain('data-testid="agent-row"')
})
+ it('does not show a prompt-cache timer on a collapsed compact summary row', async () => {
+ mockAgentActivityDisplayMode = 'compact'
+ const paneKey = makePaneKey('tab-1', LEAF_A)
+ mockAgents = [
+ mockAgent({
+ paneKey,
+ tabId: 'tab-1',
+ agentType: 'codex',
+ state: 'done',
+ startedAt: 1000,
+ prompt: 'First agent'
+ }),
+ mockAgent({
+ paneKey: makePaneKey('tab-1', LEAF_B),
+ tabId: 'tab-1',
+ agentType: 'claude',
+ state: 'done',
+ startedAt: 1500,
+ prompt: 'Second agent'
+ })
+ ]
+ mockCacheTimerByKey = { [paneKey]: 10_000 }
+ const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents')
+
+ const markup = renderToStaticMarkup()
+
+ expect(markup).toContain('All 2 agents done')
+ expect(markup).not.toContain('Prompt cache expires')
+ expect(markup).not.toContain('compact-agent-row')
+ })
+
it('keeps compact agent messages with trusted data image markdown to the single-line preview', async () => {
mockAgentActivityDisplayMode = 'compact'
mockAgents = [
diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx
index 431dc8ca322..b6a0f9f88e0 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx
@@ -42,6 +42,7 @@ function revealCompactAgentCard(agentListRoot: HTMLElement | null): void {
type Props = {
worktreeId: string
+ agents?: DashboardAgentRowData[]
/** Controls spacing from the card body above. Passed in so the parent can
* decide whether a divider is appropriate — e.g. suppressed when the card
* chrome already provides visual separation. */
@@ -58,9 +59,11 @@ type Props = {
*/
const WorktreeCardAgents = React.memo(function WorktreeCardAgents({
worktreeId,
+ agents: precomputedAgents,
className
}: Props) {
- const agents = useWorktreeAgentRows(worktreeId)
+ const selectedAgents = useWorktreeAgentRows(worktreeId, precomputedAgents === undefined)
+ const agents = precomputedAgents ?? selectedAgents
if (agents.length === 0) {
return null
}
@@ -356,7 +359,8 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
const renderCompactAgentBranch = (
agent: DashboardAgentRowData,
- ancestorPaneKeys: ReadonlySet = new Set()
+ ancestorPaneKeys: ReadonlySet = new Set(),
+ cacheTimerActive = true
): React.ReactNode => {
if (ancestorPaneKeys.has(agent.paneKey)) {
return null
@@ -391,12 +395,17 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
}
reserveDisclosureGutter={isRootAgent && anyRootHasChildren && !hasChildAgents}
isFocusedPane={agent.paneKey === focusedAgentPaneKey}
+ cacheTimerActive={cacheTimerActive}
/>
{hasChildAgents ? (
{childAgents.map((childAgent) =>
- renderCompactAgentBranch(childAgent, descendantAncestorPaneKeys)
+ renderCompactAgentBranch(
+ childAgent,
+ descendantAncestorPaneKeys,
+ cacheTimerActive && expanded
+ )
)}
@@ -444,7 +453,9 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
}}
/>
- {rootAgents.map((rootAgent) => renderCompactAgentBranch(rootAgent))}
+ {rootAgents.map((rootAgent) =>
+ renderCompactAgentBranch(rootAgent, new Set(), compactRootListExpanded)
+ )}
) : (
diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx
index 4fdb666088a..3f5d19aae57 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx
@@ -84,6 +84,53 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).not.toContain('text-amber-500')
})
+ it('suppresses the new-card unread badge while unread status is working', () => {
+ mocks.status = 'working'
+ const markup = renderToStaticMarkup(
+
+ )
+
+ expect(markup).toContain('Working · Unread')
+ expect(markup).toContain('border-yellow-500')
+ expect(markup).not.toContain('data-worktree-status-lane-unread=""')
+ expect(markup).not.toContain('data-worktree-unread-alert=""')
+ expect(markup).not.toContain('aria-label="Mark as read"')
+ expect(markup).not.toContain('lucide-bell')
+ expect(markup).not.toContain('text-amber-500')
+ })
+
+ it('keeps legacy unread working cards on the unread bell control', () => {
+ mocks.status = 'working'
+ const markup = renderToStaticMarkup(
+
+ )
+
+ expect(markup).toContain('aria-label="Mark as read"')
+ expect(markup).toContain('Mark as read')
+ expect(markup).toContain('Working')
+ expect(markup).toContain('text-amber-500')
+ expect(markup).not.toContain('border-yellow-500')
+ expect(markup).not.toContain('data-worktree-unread-alert=""')
+ })
+
it('shows status in the unread toggle affordance', () => {
const markup = renderToStaticMarkup(
const passiveStatus =
@@ -173,7 +176,7 @@ export function WorktreeCardStatusSlot({
}
if (!unreadActionEnabled) {
- return overlayNewCardUnreadStatus(passiveStatus, newCardStyle && isUnread && showStatus)
+ return overlayNewCardUnreadStatus(passiveStatus, showNewCardUnreadAlert)
}
const actionLabel = isUnread ? 'Mark as read' : 'Mark as unread'
diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx
index c303866f52e..34db3fe66a7 100644
--- a/src/renderer/src/components/sidebar/WorktreeList.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeList.tsx
@@ -29,6 +29,7 @@ import type { AppState } from '@/store/types'
import {
getAllWorktreesFromState,
useAllWorktrees,
+ useProjectHostSetupProjection,
useRepoMap,
useWorktreeMap
} from '@/store/selectors'
@@ -5332,11 +5333,13 @@ const WorktreeList = React.memo(function WorktreeList({
// Why: manual repo header order is bound to state.repos. Recent/Smart derive
// header order from the sorted visible worktree stream instead.
const repos = useAppStore((s) => s.repos)
- const projects = useAppStore((s) => s.projects)
- const projectHostSetups = useAppStore((s) => s.projectHostSetups)
+ const projectHostSetupProjection = useProjectHostSetupProjection()
const projectGrouping = useMemo(
- () => ({ projects, projectHostSetups }),
- [projectHostSetups, projects]
+ () => ({
+ projects: projectHostSetupProjection.projects,
+ projectHostSetups: projectHostSetupProjection.setups
+ }),
+ [projectHostSetupProjection]
)
const projectGroups = useAppStore((s) => s.projectGroups ?? EMPTY_PROJECT_GROUPS)
const folderWorkspaces = useAppStore((s) => s.folderWorkspaces)
diff --git a/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts
new file mode 100644
index 00000000000..6f894f065db
--- /dev/null
+++ b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts
@@ -0,0 +1,217 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+type SeedWorktree = { id: string; repoId: string; isMainWorktree: boolean }
+
+const mocks = vi.hoisted(() => {
+ const state = {
+ activeView: 'terminal',
+ activePendingCreationId: null as string | null,
+ activeWorktreeId: null as string | null,
+ worktreesByRepo: {} as Record,
+ lastVisitedAtByWorktreeId: {} as Record,
+ deleteStateByWorktreeId: {} as Record,
+ worktreeMap: new Map()
+ }
+ return { state }
+})
+
+vi.mock('@/store', () => ({
+ useAppStore: {
+ getState: () => mocks.state
+ }
+}))
+
+vi.mock('@/store/selectors', () => ({
+ getWorktreeMapFromState: () => mocks.state.worktreeMap
+}))
+
+vi.mock('@/lib/worktree-activation', () => ({
+ activateAndRevealWorktree: vi.fn()
+}))
+
+import { activateAndRevealWorktree } from '@/lib/worktree-activation'
+import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-after-delete'
+
+function seed(worktrees: { id: string; repoId?: string; isMainWorktree?: boolean }[]): void {
+ const normalized: SeedWorktree[] = worktrees.map((worktree) => ({
+ id: worktree.id,
+ repoId: worktree.repoId ?? 'repo-1',
+ isMainWorktree: worktree.isMainWorktree ?? false
+ }))
+ mocks.state.worktreeMap = new Map(normalized.map((worktree) => [worktree.id, worktree]))
+ const byRepo: Record = {}
+ for (const worktree of normalized) {
+ ;(byRepo[worktree.repoId] ??= []).push(worktree)
+ }
+ mocks.state.worktreesByRepo = byRepo
+}
+
+// Why: mirror the store reducer — once a delete resolves, the removed worktree
+// is gone from the worktree map and worktreesByRepo, its lastVisited entry is
+// cleared, and activeWorktreeId is nulled only when it was the deleted worktree.
+function simulateDelete(worktreeId: string, nulledActive: boolean): void {
+ mocks.state.worktreeMap.delete(worktreeId)
+ for (const repoId of Object.keys(mocks.state.worktreesByRepo)) {
+ mocks.state.worktreesByRepo[repoId] = mocks.state.worktreesByRepo[repoId].filter(
+ (worktree) => worktree.id !== worktreeId
+ )
+ }
+ delete mocks.state.lastVisitedAtByWorktreeId[worktreeId]
+ if (nulledActive) {
+ mocks.state.activeWorktreeId = null
+ }
+}
+
+describe('prepareActiveWorktreeFocusAfterDelete', () => {
+ beforeEach(() => {
+ mocks.state.activeView = 'terminal'
+ mocks.state.activePendingCreationId = null
+ mocks.state.activeWorktreeId = null
+ mocks.state.worktreesByRepo = {}
+ mocks.state.lastVisitedAtByWorktreeId = {}
+ mocks.state.deleteStateByWorktreeId = {}
+ mocks.state.worktreeMap = new Map()
+ vi.mocked(activateAndRevealWorktree).mockClear()
+ })
+
+ it('focuses the most-recently-visited non-base sibling of the same project', () => {
+ seed([{ id: 'main', isMainWorktree: true }, { id: 'wt-a' }, { id: 'wt-b' }, { id: 'wt-del' }])
+ mocks.state.activeWorktreeId = 'wt-del'
+ mocks.state.lastVisitedAtByWorktreeId = { 'wt-a': 100, 'wt-b': 200 }
+
+ const commit = prepareActiveWorktreeFocusAfterDelete('wt-del')
+ simulateDelete('wt-del', true)
+ commit()
+
+ expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-b')
+ })
+
+ it('falls back to the base/primary worktree when no other workspace remains', () => {
+ seed([{ id: 'main', isMainWorktree: true }, { id: 'wt-del' }])
+ mocks.state.activeWorktreeId = 'wt-del'
+
+ const commit = prepareActiveWorktreeFocusAfterDelete('wt-del')
+ simulateDelete('wt-del', true)
+ commit()
+
+ expect(activateAndRevealWorktree).toHaveBeenCalledWith('main')
+ })
+
+ it('stays within the deleted worktree project instead of jumping to another project', () => {
+ seed([
+ { id: 'main-1', repoId: 'repo-1', isMainWorktree: true },
+ { id: 'wt-del', repoId: 'repo-1' },
+ { id: 'wt-other', repoId: 'repo-2' },
+ { id: 'main-2', repoId: 'repo-2', isMainWorktree: true }
+ ])
+ mocks.state.activeWorktreeId = 'wt-del'
+
+ const commit = prepareActiveWorktreeFocusAfterDelete('wt-del')
+ simulateDelete('wt-del', true)
+ commit()
+
+ expect(activateAndRevealWorktree).toHaveBeenCalledWith('main-1')
+ })
+
+ it('does not steal focus when the deleted worktree was not the active one', () => {
+ seed([{ id: 'main', isMainWorktree: true }, { id: 'wt-active' }, { id: 'wt-del' }])
+ mocks.state.activeWorktreeId = 'wt-active'
+
+ const commit = prepareActiveWorktreeFocusAfterDelete('wt-del')
+ simulateDelete('wt-del', false)
+ commit()
+
+ expect(activateAndRevealWorktree).not.toHaveBeenCalled()
+ })
+
+ it('does not reclaim focus when the user navigated away during the delete', () => {
+ seed([{ id: 'main', isMainWorktree: true }, { id: 'wt-a' }, { id: 'wt-del' }])
+ mocks.state.activeWorktreeId = 'wt-del'
+
+ const commit = prepareActiveWorktreeFocusAfterDelete('wt-del')
+ simulateDelete('wt-del', false)
+ // Why: a concurrent activation moved focus before the delete settled.
+ mocks.state.activeWorktreeId = 'wt-a'
+ commit()
+
+ expect(activateAndRevealWorktree).not.toHaveBeenCalled()
+ })
+
+ it('does not steal focus when the delete starts from a non-terminal view', () => {
+ seed([{ id: 'main', isMainWorktree: true }, { id: 'wt-del' }])
+ // Why: top-level views can retain the last terminal worktree id without
+ // meaning the user is currently looking at that workspace.
+ mocks.state.activeView = 'space'
+ mocks.state.activeWorktreeId = 'wt-del'
+
+ const commit = prepareActiveWorktreeFocusAfterDelete('wt-del')
+ simulateDelete('wt-del', true)
+ commit()
+
+ expect(activateAndRevealWorktree).not.toHaveBeenCalled()
+ })
+
+ it('does not reclaim focus when the user leaves terminal view during the delete', () => {
+ seed([{ id: 'main', isMainWorktree: true }, { id: 'wt-del' }])
+ mocks.state.activeWorktreeId = 'wt-del'
+
+ const commit = prepareActiveWorktreeFocusAfterDelete('wt-del')
+ simulateDelete('wt-del', true)
+ mocks.state.activeView = 'settings'
+ commit()
+
+ expect(activateAndRevealWorktree).not.toHaveBeenCalled()
+ })
+
+ it('does not steal focus when a pending creation panel is active before delete', () => {
+ seed([{ id: 'main', isMainWorktree: true }, { id: 'wt-del' }])
+ // Why: pending creation keeps the prior worktree id while the creation
+ // surface owns the content area, so it is not viewing the old workspace.
+ mocks.state.activePendingCreationId = 'creation-1'
+ mocks.state.activeWorktreeId = 'wt-del'
+
+ const commit = prepareActiveWorktreeFocusAfterDelete('wt-del')
+ simulateDelete('wt-del', true)
+ commit()
+
+ expect(activateAndRevealWorktree).not.toHaveBeenCalled()
+ })
+
+ it('does not reclaim focus when pending creation opens during the delete', () => {
+ seed([{ id: 'main', isMainWorktree: true }, { id: 'wt-del' }])
+ mocks.state.activeWorktreeId = 'wt-del'
+
+ const commit = prepareActiveWorktreeFocusAfterDelete('wt-del')
+ simulateDelete('wt-del', true)
+ mocks.state.activePendingCreationId = 'creation-1'
+ commit()
+
+ expect(activateAndRevealWorktree).not.toHaveBeenCalled()
+ })
+
+ it('skips workspaces that are themselves mid-delete when picking a successor', () => {
+ seed([{ id: 'main', isMainWorktree: true }, { id: 'wt-a' }, { id: 'wt-del' }])
+ mocks.state.activeWorktreeId = 'wt-del'
+ mocks.state.lastVisitedAtByWorktreeId = { 'wt-a': 50 }
+
+ const commit = prepareActiveWorktreeFocusAfterDelete('wt-del')
+ simulateDelete('wt-del', true)
+ mocks.state.deleteStateByWorktreeId = { 'wt-a': { isDeleting: true } }
+ commit()
+
+ expect(activateAndRevealWorktree).toHaveBeenCalledWith('main')
+ })
+
+ it('does not steal focus when a non-worktree workspace is active', () => {
+ seed([{ id: 'main', isMainWorktree: true }, { id: 'wt-del' }])
+ // Why: folder workspaces hold a non-worktree key in activeWorktreeId, so a
+ // background worktree delete must never move the user off it.
+ mocks.state.activeWorktreeId = 'folder:abc'
+
+ const commit = prepareActiveWorktreeFocusAfterDelete('wt-del')
+ simulateDelete('wt-del', false)
+ commit()
+
+ expect(activateAndRevealWorktree).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts
new file mode 100644
index 00000000000..c3ffc94bdbc
--- /dev/null
+++ b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts
@@ -0,0 +1,73 @@
+import { useAppStore } from '@/store'
+import { getWorktreeMapFromState } from '@/store/selectors'
+import { activateAndRevealWorktree } from '@/lib/worktree-activation'
+
+type AppStoreState = ReturnType
+
+// Why: after deleting the workspace the user is currently viewing, leaving the
+// active workspace empty loses their place. Pick the next workspace to focus
+// so a delete behaves like closing a tab — prefer another non-base/primary
+// workspace of the same project (most-recently-visited first), and fall back to
+// the project's base/primary workspace when no other workspace remains.
+function pickNextWorktreeIdAfterDelete(
+ state: AppStoreState,
+ repoId: string,
+ deletedWorktreeId: string
+): string | null {
+ const deleteState = state.deleteStateByWorktreeId
+ const siblings = (state.worktreesByRepo[repoId] ?? []).filter(
+ (worktree) => worktree.id !== deletedWorktreeId && !deleteState[worktree.id]?.isDeleting
+ )
+ const others = siblings.filter((worktree) => !worktree.isMainWorktree)
+ if (others.length > 0) {
+ const lastVisited = state.lastVisitedAtByWorktreeId
+ const [mostRecent] = [...others].sort(
+ (a, b) => (lastVisited[b.id] ?? 0) - (lastVisited[a.id] ?? 0)
+ )
+ return mostRecent.id
+ }
+ return siblings.find((worktree) => worktree.isMainWorktree)?.id ?? null
+}
+
+function focusNextWorktreeAfterActiveDelete(
+ deletedWorktreeId: string,
+ repoId: string | null,
+ wasViewingBeforeDelete: boolean
+): void {
+ if (!wasViewingBeforeDelete || !repoId) {
+ return
+ }
+ const state = useAppStore.getState()
+ // Why: a concurrent activation may have already moved focus during the delete.
+ // Only hand off when deletion left the terminal workspace selection empty.
+ if (
+ state.activeView !== 'terminal' ||
+ state.activePendingCreationId !== null ||
+ state.activeWorktreeId !== null
+ ) {
+ return
+ }
+ const nextWorktreeId = pickNextWorktreeIdAfterDelete(state, repoId, deletedWorktreeId)
+ if (nextWorktreeId) {
+ activateAndRevealWorktree(nextWorktreeId)
+ }
+}
+
+/**
+ * Capture, before a delete runs, whether the target is the workspace the user is
+ * currently viewing. Returns a committer to call after a successful delete: it
+ * focuses the next-best workspace only when the deleted one was active, so
+ * deleting a background workspace never steals the user's current focus.
+ *
+ * Captured up front because the worktree record (and its repoId) is gone from the
+ * store once the delete resolves.
+ */
+export function prepareActiveWorktreeFocusAfterDelete(worktreeId: string): () => void {
+ const state = useAppStore.getState()
+ const wasViewing =
+ state.activeView === 'terminal' &&
+ state.activePendingCreationId === null &&
+ state.activeWorktreeId === worktreeId
+ const repoId = getWorktreeMapFromState(state).get(worktreeId)?.repoId ?? null
+ return () => focusNextWorktreeAfterActiveDelete(worktreeId, repoId, wasViewing)
+}
diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.ts
index 2195f48bd65..ae9d612164d 100644
--- a/src/renderer/src/components/sidebar/delete-worktree-flow.ts
+++ b/src/renderer/src/components/sidebar/delete-worktree-flow.ts
@@ -2,6 +2,7 @@ import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { getWorktreeMapFromState } from '@/store/selectors'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
+import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-after-delete'
import { getDeleteWorktreeToastCopy } from './delete-worktree-toast'
import { getWorkspaceDeleteLineage } from './workspace-delete-lineage'
import {
@@ -19,6 +20,9 @@ type WorktreeBatchDeleteOptions = {
type WorktreeDeleteWithToastOptions = {
force?: boolean
onForceDeleted?: (worktreeId: string) => void
+ // Why: batch deletes suppress the per-delete focus handoff and instead focus a
+ // single survivor after the whole batch settles (see runWorktreeDeletesInParallel).
+ focusSuccessorOnDelete?: boolean
}
// Why: a failed delete almost always means the worktree still has changes
@@ -44,6 +48,12 @@ export async function runWorktreeDeletesInParallel(
targets: readonly Pick[],
options: WorktreeDeleteWithToastOptions = {}
): Promise {
+ // Why: capture the viewed workspace before any delete runs so we can focus a
+ // single survivor once the batch settles, rather than per delete.
+ const activeWorktreeIdBefore = useAppStore.getState().activeWorktreeId
+ const commitBatchFocus = activeWorktreeIdBefore
+ ? prepareActiveWorktreeFocusAfterDelete(activeWorktreeIdBefore)
+ : null
// Why: deletes are serialized per repo to avoid git lock races, but every
// selected/lineage workspace should show in-flight feedback immediately.
useAppStore.getState().markWorktreesDeleting(targets.map((target) => target.id))
@@ -76,7 +86,10 @@ export async function runWorktreeDeletesInParallel(
useAppStore.getState().clearWorktreeDeleteState(target.id)
continue
}
- const deleted = await runWorktreeDeleteWithToast(target.id, target.displayName, options)
+ const deleted = await runWorktreeDeleteWithToast(target.id, target.displayName, {
+ ...options,
+ focusSuccessorOnDelete: false
+ })
if (deleted) {
deletedInGroup.push(target.id)
} else {
@@ -89,6 +102,12 @@ export async function runWorktreeDeletesInParallel(
})
)
const deletedSet = new Set(groupResults.flat())
+ // Why: focus a survivor once, after the batch settles, rather than per delete —
+ // an intermediate focus could land on (and spawn a terminal in) a workspace this
+ // same batch is about to delete.
+ if (activeWorktreeIdBefore && deletedSet.has(activeWorktreeIdBefore)) {
+ commitBatchFocus?.()
+ }
return targets.filter((target) => deletedSet.has(target.id)).map((target) => target.id)
}
@@ -111,10 +130,17 @@ export function runWorktreeDeleteWithToast(
options: WorktreeDeleteWithToastOptions = {}
): Promise {
const removeWorktree = useAppStore.getState().removeWorktree
+ const commitFocus = prepareActiveWorktreeFocusAfterDelete(worktreeId)
+ const focusSuccessor = options.focusSuccessorOnDelete !== false
return removeWorktree(worktreeId, options.force === true)
.then((result) => {
if (result.ok) {
+ // Why: keep the user on a live workspace instead of the Landing screen
+ // when they delete the one they were viewing.
+ if (focusSuccessor) {
+ commitFocus()
+ }
return true
}
const state = useAppStore.getState().deleteStateByWorktreeId[worktreeId]
@@ -135,6 +161,10 @@ export function runWorktreeDeleteWithToast(
'Force Delete'
),
onClick: () => {
+ // Why: recapture at click time — the user may have navigated away
+ // while the failed-delete toast was open, so focus only hands off
+ // when this is still the workspace they are viewing.
+ const commitForceFocus = prepareActiveWorktreeFocusAfterDelete(worktreeId)
useAppStore
.getState()
.removeWorktree(worktreeId, true)
@@ -158,6 +188,7 @@ export function runWorktreeDeleteWithToast(
)
return
}
+ commitForceFocus()
options.onForceDeleted?.(worktreeId)
})
.catch((err: unknown) => {
diff --git a/src/renderer/src/components/sidebar/prompt-cache-timer-selection.test.ts b/src/renderer/src/components/sidebar/prompt-cache-timer-selection.test.ts
index a5aba378a73..03b82a45fa7 100644
--- a/src/renderer/src/components/sidebar/prompt-cache-timer-selection.test.ts
+++ b/src/renderer/src/components/sidebar/prompt-cache-timer-selection.test.ts
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest'
-import { getMostUrgentPromptCacheStartedAt } from './prompt-cache-timer-selection'
+import { makePaneKey } from '../../../../shared/stable-pane-id'
+import {
+ getMostUrgentPromptCacheStartedAt,
+ getPromptCacheCountdownForPane
+} from './prompt-cache-timer-selection'
+
+const LEAF_A = '11111111-1111-4111-8111-111111111111'
+const LEAF_B = '22222222-2222-4222-8222-222222222222'
describe('getMostUrgentPromptCacheStartedAt', () => {
it('selects the oldest non-null timer for the worktree tabs in one cache pass', () => {
@@ -23,3 +30,40 @@ describe('getMostUrgentPromptCacheStartedAt', () => {
expect(startedAt).toBe(300)
})
})
+
+describe('getPromptCacheCountdownForPane', () => {
+ it('selects the exact pane timer with the ttl used for gating', () => {
+ const paneKey = makePaneKey('tab-1', LEAF_A)
+ const otherPaneKey = makePaneKey('tab-1', LEAF_B)
+
+ expect(
+ getPromptCacheCountdownForPane(
+ paneKey,
+ {
+ [paneKey]: 300,
+ [otherPaneKey]: 100
+ },
+ 5000
+ )
+ ).toEqual({ startedAt: 300, ttlMs: 5000 })
+ })
+
+ it('does not fall back to seed timers for per-pane row ownership', () => {
+ const paneKey = makePaneKey('tab-1', LEAF_A)
+
+ expect(getPromptCacheCountdownForPane(paneKey, { 'tab-1:seed': 300 }, 5000)).toBeNull()
+ })
+
+ it('rejects malformed pane keys and null timer values', () => {
+ const paneKey = makePaneKey('tab-1', LEAF_A)
+
+ expect(getPromptCacheCountdownForPane('tab-1:1', { 'tab-1:1': 300 }, 5000)).toBeNull()
+ expect(getPromptCacheCountdownForPane(paneKey, { [paneKey]: null }, 5000)).toBeNull()
+ })
+
+ it('requires a positive ttl', () => {
+ const paneKey = makePaneKey('tab-1', LEAF_A)
+
+ expect(getPromptCacheCountdownForPane(paneKey, { [paneKey]: 300 }, 0)).toBeNull()
+ })
+})
diff --git a/src/renderer/src/components/sidebar/prompt-cache-timer-selection.ts b/src/renderer/src/components/sidebar/prompt-cache-timer-selection.ts
index 87c63da1a71..69232f64f6f 100644
--- a/src/renderer/src/components/sidebar/prompt-cache-timer-selection.ts
+++ b/src/renderer/src/components/sidebar/prompt-cache-timer-selection.ts
@@ -1,4 +1,10 @@
import type { TerminalTab } from '../../../../shared/types'
+import { parsePaneKey } from '../../../../shared/stable-pane-id'
+
+export type PromptCacheCountdownSelection = {
+ startedAt: number
+ ttlMs: number
+}
function getCacheTimerTabId(key: string): string | null {
const separator = key.indexOf(':')
@@ -28,3 +34,15 @@ export function getMostUrgentPromptCacheStartedAt(
}
return oldest
}
+
+export function getPromptCacheCountdownForPane(
+ paneKey: string,
+ cacheTimerByKey: Record,
+ ttlMs: number
+): PromptCacheCountdownSelection | null {
+ if (ttlMs <= 0 || parsePaneKey(paneKey) === null) {
+ return null
+ }
+ const startedAt = cacheTimerByKey[paneKey]
+ return startedAt == null ? null : { startedAt, ttlMs }
+}
diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts
index be3ab7fd427..9cfc00e32b6 100644
--- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts
+++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts
@@ -35,42 +35,47 @@ export {
* store slice and then shared by every visible card, avoiding O(cards × agents)
* selector work on high-frequency agent status pings.
*/
-export function useWorktreeAgentRows(worktreeId: string): DashboardAgentRow[] {
- const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId])
+export function useWorktreeAgentRows(worktreeId: string, active = true): DashboardAgentRow[] {
+ const tabs = useAppStore((s) => (active ? s.tabsByWorktree[worktreeId] : undefined))
// Why: narrow the subscriptions to only THIS worktree's entries via
// useShallow. Subscribing to the whole agentStatusByPaneKey map would make
// every on-screen card re-render on any agent-status update anywhere —
// O(worktrees²) render amplification. Pre-filtering here means the card
// only re-renders when something relevant to THIS worktree changes.
const liveEntries = useAppStore(
- useShallow((s) => selectLiveAgentStatusEntriesForWorktree(s, worktreeId))
+ useShallow((s) => (active ? selectLiveAgentStatusEntriesForWorktree(s, worktreeId) : []))
)
// Why: keep the store selector limited to stable raw records. Converting
// migration entries creates fresh objects with Date.now(), which breaks
// useSyncExternalStore's cached-snapshot contract and can blank Electron.
const migrationUnsupported = useAppStore(
- useShallow((s) => selectMigrationUnsupportedEntriesForWorktree(s, worktreeId))
+ useShallow((s) => (active ? selectMigrationUnsupportedEntriesForWorktree(s, worktreeId) : []))
)
const retained = useAppStore(
- useShallow((s) => selectRetainedAgentEntriesForWorktree(s, worktreeId))
+ useShallow((s) => (active ? selectRetainedAgentEntriesForWorktree(s, worktreeId) : []))
)
const runtimePaneTitlesByTabId = useAppStore(
- useShallow((s) => selectRuntimePaneTitlesForWorktree(s, worktreeId))
+ useShallow((s) => (active ? selectRuntimePaneTitlesForWorktree(s, worktreeId) : {}))
+ )
+ const ptyIdsByTabId = useAppStore(
+ useShallow((s) => (active ? selectLivePtyIdsForWorktree(s, worktreeId) : {}))
)
- const ptyIdsByTabId = useAppStore(useShallow((s) => selectLivePtyIdsForWorktree(s, worktreeId)))
const terminalLayoutsByTabId = useAppStore(
- useShallow((s) => selectTerminalLayoutsForWorktree(s, worktreeId))
+ useShallow((s) => (active ? selectTerminalLayoutsForWorktree(s, worktreeId) : {}))
)
const runtimeAgentOrchestrationByPaneKey = useAppStore(
- useShallow((s) => selectRuntimeAgentOrchestrationForWorktree(s, worktreeId))
+ useShallow((s) => (active ? selectRuntimeAgentOrchestrationForWorktree(s, worktreeId) : {}))
)
// Why: agentStatusEpoch is included in the dependency array (but not in the
// computation itself) so the memo recomputes when freshness boundaries
// expire, even if no new PTY data arrives — same rationale as
// useDashboardData.
- const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
+ const agentStatusEpoch = useAppStore((s) => (active ? s.agentStatusEpoch : 0))
return useMemo(() => {
+ if (!active) {
+ return []
+ }
// Why: Date.now() is read inside the memo (not as a dep) so stale-decay
// recalculates whenever agentStatusEpoch ticks — same pattern as
// useDashboardData.
@@ -99,6 +104,7 @@ export function useWorktreeAgentRows(worktreeId: string): DashboardAgentRow[] {
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
+ active,
tabs,
liveEntries,
migrationUnsupported,
diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts
index 546618af11f..8fe9290ba9f 100644
--- a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts
+++ b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts
@@ -12,6 +12,9 @@ const EMPTY_LIVE_ENTRIES: AgentStatusEntry[] = []
const EMPTY_MIGRATION_UNSUPPORTED_ENTRIES: MigrationUnsupportedPtyEntry[] = []
const EMPTY_RETAINED: RetainedAgentEntry[] = []
const EMPTY_RUNTIME_AGENT_ORCHESTRATION: Record = {}
+// Why: selector unit tests often pass partial store mocks; production state
+// owns these maps, but missing mock maps should behave like empty slices.
+const EMPTY_RECORD = {}
type WorktreeAgentRowsState = Pick<
AppState,
@@ -77,17 +80,19 @@ function getTabIdToWorktreeId(
}
function getLiveEntriesByWorktree(state: WorktreeAgentRowsState): Map {
+ const agentStatusByPaneKey = state.agentStatusByPaneKey ?? EMPTY_RECORD
+ const tabsByWorktree = state.tabsByWorktree ?? EMPTY_RECORD
if (
- liveEntriesByWorktreeCache?.tabsByWorktree === state.tabsByWorktree &&
- liveEntriesByWorktreeCache.agentStatusByPaneKey === state.agentStatusByPaneKey
+ liveEntriesByWorktreeCache?.tabsByWorktree === tabsByWorktree &&
+ liveEntriesByWorktreeCache.agentStatusByPaneKey === agentStatusByPaneKey
) {
return liveEntriesByWorktreeCache.entriesByWorktree
}
- const tabIdToWorktreeId = getTabIdToWorktreeId(state.tabsByWorktree)
+ const tabIdToWorktreeId = getTabIdToWorktreeId(tabsByWorktree)
const previous = liveEntriesByWorktreeCache?.entriesByWorktree
const entriesByWorktree = new Map()
- for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) {
+ for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) {
const parsed = parsePaneKey(paneKey)
if (!parsed) {
continue
@@ -107,8 +112,8 @@ function getLiveEntriesByWorktree(state: WorktreeAgentRowsState): Map {
+ const migrationUnsupportedByPtyId = state.migrationUnsupportedByPtyId ?? EMPTY_RECORD
+ const tabsByWorktree = state.tabsByWorktree ?? EMPTY_RECORD
if (
- migrationUnsupportedByWorktreeCache?.tabsByWorktree === state.tabsByWorktree &&
- migrationUnsupportedByWorktreeCache.migrationUnsupportedByPtyId ===
- state.migrationUnsupportedByPtyId
+ migrationUnsupportedByWorktreeCache?.tabsByWorktree === tabsByWorktree &&
+ migrationUnsupportedByWorktreeCache.migrationUnsupportedByPtyId === migrationUnsupportedByPtyId
) {
return migrationUnsupportedByWorktreeCache.entriesByWorktree
}
- const tabIdToWorktreeId = getTabIdToWorktreeId(state.tabsByWorktree)
+ const tabIdToWorktreeId = getTabIdToWorktreeId(tabsByWorktree)
const previous = migrationUnsupportedByWorktreeCache?.entriesByWorktree
const entriesByWorktree = new Map()
- for (const unsupported of Object.values(state.migrationUnsupportedByPtyId)) {
+ for (const unsupported of Object.values(migrationUnsupportedByPtyId)) {
if (!unsupported.paneKey) {
continue
}
@@ -148,8 +154,8 @@ function getMigrationUnsupportedByWorktree(
entriesByWorktree.set(worktreeId, reuseArrayIfEqual(previous?.get(worktreeId), entries))
}
migrationUnsupportedByWorktreeCache = {
- tabsByWorktree: state.tabsByWorktree,
- migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId,
+ tabsByWorktree,
+ migrationUnsupportedByPtyId,
entriesByWorktree
}
return entriesByWorktree
@@ -158,13 +164,14 @@ function getMigrationUnsupportedByWorktree(
function getRetainedEntriesByWorktree(
state: WorktreeAgentRowsState
): Map {
- if (retainedEntriesByWorktreeCache?.retainedAgentsByPaneKey === state.retainedAgentsByPaneKey) {
+ const retainedAgentsByPaneKey = state.retainedAgentsByPaneKey ?? EMPTY_RECORD
+ if (retainedEntriesByWorktreeCache?.retainedAgentsByPaneKey === retainedAgentsByPaneKey) {
return retainedEntriesByWorktreeCache.entriesByWorktree
}
const previous = retainedEntriesByWorktreeCache?.entriesByWorktree
const entriesByWorktree = new Map()
- for (const retained of Object.values(state.retainedAgentsByPaneKey)) {
+ for (const retained of Object.values(retainedAgentsByPaneKey)) {
const bucket = entriesByWorktree.get(retained.worktreeId)
if (bucket) {
bucket.push(retained)
@@ -176,7 +183,7 @@ function getRetainedEntriesByWorktree(
entriesByWorktree.set(worktreeId, reuseArrayIfEqual(previous?.get(worktreeId), entries))
}
retainedEntriesByWorktreeCache = {
- retainedAgentsByPaneKey: state.retainedAgentsByPaneKey,
+ retainedAgentsByPaneKey,
entriesByWorktree
}
return entriesByWorktree
@@ -215,16 +222,20 @@ export function selectRuntimeAgentOrchestrationForWorktree(
>,
worktreeId: string
): Record {
- const tabs = state.tabsByWorktree[worktreeId] ?? []
+ const tabs = (state.tabsByWorktree ?? EMPTY_RECORD)[worktreeId] ?? []
const tabIds = new Set(tabs.map((tab) => tab.id))
const out: Record = {}
- for (const [paneKey, orchestration] of Object.entries(state.runtimeAgentOrchestrationByPaneKey)) {
+ const runtimeAgentOrchestrationByPaneKey =
+ state.runtimeAgentOrchestrationByPaneKey ?? EMPTY_RECORD
+ const agentStatusByPaneKey = state.agentStatusByPaneKey ?? EMPTY_RECORD
+ const retainedAgentsByPaneKey = state.retainedAgentsByPaneKey ?? EMPTY_RECORD
+ for (const [paneKey, orchestration] of Object.entries(runtimeAgentOrchestrationByPaneKey)) {
const parsed = parsePaneKey(paneKey)
const parsedParent = orchestration.parentPaneKey
? parsePaneKey(orchestration.parentPaneKey)
: null
- const liveEntry = state.agentStatusByPaneKey[paneKey]
- const retainedEntry = state.retainedAgentsByPaneKey[paneKey]
+ const liveEntry = agentStatusByPaneKey[paneKey]
+ const retainedEntry = retainedAgentsByPaneKey[paneKey]
// Why: child agent terminals can be attributed to a worktree before their
// tab reaches this renderer, or after the row has been retained as done.
// The parent link must still reach that worktree card.
@@ -245,8 +256,8 @@ export function selectTerminalLayoutsForWorktree(
worktreeId: string
): Record {
const out: Record = {}
- for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
- out[tab.id] = state.terminalLayoutsByTabId[tab.id]
+ for (const tab of (state.tabsByWorktree ?? EMPTY_RECORD)[worktreeId] ?? []) {
+ out[tab.id] = (state.terminalLayoutsByTabId ?? EMPTY_RECORD)[tab.id]
}
return out
}
diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx
index 5f7a0b6ad35..5e75a7f3de3 100644
--- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx
+++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils'
import { getAgentDotState } from './worktree-card-agent-summary'
import { translate } from '@/i18n/i18n'
import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
+import CacheTimer, { usePromptCacheCountdownForPane } from './CacheTimer'
function formatShortTimeAgo(ts: number, now: number): string {
const delta = now - ts
@@ -92,6 +93,7 @@ type CompactAgentRowProps = {
reserveDisclosureGutter?: boolean
isFocusedPane?: boolean
hideIdentityIcon?: boolean
+ cacheTimerActive?: boolean
}
export const CompactAgentRow = React.memo(function CompactAgentRow({
@@ -106,7 +108,8 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
onToggleChildAgents,
reserveDisclosureGutter = false,
isFocusedPane = false,
- hideIdentityIcon = false
+ hideIdentityIcon = false,
+ cacheTimerActive = true
}: CompactAgentRowProps) {
const hasChildDisclosure =
typeof childAgentCount === 'number' &&
@@ -117,6 +120,7 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
const isLineageChild = agent.lineage?.depth === 1
const secondary = getCompactAgentSecondary(agent)
const shortTime = getCompactAgentTime(agent, now)
+ const cacheTimer = usePromptCacheCountdownForPane(agent.paneKey, cacheTimerActive)
const handleActivate = useCallback(
(e: React.MouseEvent) => {
@@ -213,6 +217,7 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
+{childAgentCount}
)}
+ {cacheTimer && }
{shortTime && (
{
])
})
+ it('renders same-project records with git remote identity as one mixed-host project header', () => {
+ const localRepo: Repo = {
+ ...repo,
+ id: 'local-sample-app',
+ path: '/Users/alice/work/sample-app',
+ displayName: 'sample-app',
+ gitRemoteIdentity: {
+ canonicalKey: 'git.company.test/team/sample-app',
+ remoteName: 'origin',
+ remoteUrl: 'git@git.company.test:team/sample-app.git'
+ }
+ }
+ const sshRepo: Repo = {
+ ...repo,
+ id: 'ssh-sample-app',
+ path: '/home/alice/src/sample-app',
+ displayName: 'sample-app',
+ connectionId: 'build server',
+ gitRemoteIdentity: {
+ canonicalKey: 'git.company.test/team/sample-app',
+ remoteName: 'origin',
+ remoteUrl: 'https://git.company.test/team/sample-app.git'
+ }
+ }
+ const runtimeRepo: Repo = {
+ ...repo,
+ id: 'runtime-sample-app',
+ path: '/workspace/sample-app',
+ displayName: 'sample-app',
+ executionHostId: 'runtime:dev-container',
+ gitRemoteIdentity: {
+ canonicalKey: 'git.company.test/team/sample-app',
+ remoteName: 'origin',
+ remoteUrl: 'ssh://git@git.company.test/team/sample-app.git'
+ }
+ }
+ const localWorktree: Worktree = {
+ ...worktree,
+ id: 'wt-local-sample-app',
+ repoId: localRepo.id,
+ path: '/Users/alice/work/sample-app-feature'
+ }
+ const sshWorktree: Worktree = {
+ ...worktree,
+ id: 'wt-ssh-sample-app',
+ repoId: sshRepo.id,
+ path: '/home/alice/src/sample-app-feature'
+ }
+ const runtimeWorktree: Worktree = {
+ ...worktree,
+ id: 'wt-runtime-sample-app',
+ repoId: runtimeRepo.id,
+ path: '/workspace/sample-app-feature'
+ }
+ const projection = projectHostSetupProjectionFromRepos([localRepo, sshRepo, runtimeRepo])
+ const rows = buildRows(
+ 'repo',
+ [localWorktree, sshWorktree, runtimeWorktree],
+ new Map([
+ [localRepo.id, localRepo],
+ [sshRepo.id, sshRepo],
+ [runtimeRepo.id, runtimeRepo]
+ ]),
+ null,
+ new Set(),
+ undefined,
+ undefined,
+ undefined,
+ {},
+ new Map([
+ [localWorktree.id, localWorktree],
+ [sshWorktree.id, sshWorktree],
+ [runtimeWorktree.id, runtimeWorktree]
+ ]),
+ false,
+ undefined,
+ [],
+ new Set(),
+ new Map(),
+ [],
+ {
+ projects: projection.projects,
+ projectHostSetups: projection.setups
+ }
+ )
+
+ expect(rows).toMatchObject([
+ {
+ type: 'header',
+ key: 'project:git:git.company.test/team/sample-app',
+ label: 'sample-app',
+ count: 3
+ },
+ { type: 'item', worktree: { id: localWorktree.id }, hostContextLabel: LOCAL_HOST_LABEL },
+ { type: 'item', worktree: { id: sshWorktree.id }, hostContextLabel: 'build server' },
+ { type: 'item', worktree: { id: runtimeWorktree.id }, hostContextLabel: 'dev-container' }
+ ])
+ })
+
it('orders project identity headers by the manual repo order anchor', () => {
const analyticsProject: Project = {
...project,
@@ -2506,6 +2606,13 @@ describe('WorktreeList header styles', () => {
expect(source).toContain('headerKey: row.key')
expect(source).toContain('color={repoHeaderColor}')
})
+
+ it('adapts projected setup rows for sidebar project grouping', () => {
+ const source = readWorktreeListSource()
+
+ expect(source).toContain('const projectHostSetupProjection = useProjectHostSetupProjection()')
+ expect(source).toContain('projectHostSetups: projectHostSetupProjection.setups')
+ })
})
describe('buildRows pending creations', () => {
diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
index 7e759dbaac7..2090a9e708e 100644
--- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
+++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
@@ -43,6 +43,7 @@ import { getHostedReviewCacheKey } from '../../store/slices/hosted-review'
import { issueCacheKey as getIssueCacheKey } from '../../store/slices/github'
import { refreshGitStatusForWorktree } from '../right-sidebar/git-status-refresh'
import { runWorktreeBatchDelete } from '../sidebar/delete-worktree-flow'
+import { prepareActiveWorktreeFocusAfterDelete } from '../sidebar/active-worktree-focus-after-delete'
import { branchDisplayName } from '../sidebar/WorktreeCardHelpers'
import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
@@ -1592,6 +1593,7 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
(worktree: WorkspaceSpaceWorktree): void => {
// Why: Space keeps normal deletes non-force so uncommitted work is not
// discarded silently; a failed row gets this explicit recovery path.
+ const commitFocus = prepareActiveWorktreeFocusAfterDelete(worktree.worktreeId)
void removeWorktree(worktree.worktreeId, true)
.then((result) => {
if (!result.ok) {
@@ -1606,6 +1608,7 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
)
return
}
+ commitFocus()
handleDeletedWorktrees([worktree.worktreeId])
})
.catch((error: unknown) => {
diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx
index 91587d9d0e5..f9d791d3cbb 100644
--- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx
+++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx
@@ -189,6 +189,8 @@ export default function AiVaultSessionDropLayer({
agent: payload.agent,
worktreeId,
command: payload.command,
+ ...(payload.env ? { env: payload.env } : {}),
+ ...(payload.launchConfig ? { launchConfig: payload.launchConfig } : {}),
targetGroupId: dropTarget.groupId,
splitDirection: dropTarget.zone === 'center' ? undefined : dropTarget.zone
})
diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx
index 991cfea8043..58853e0fcf4 100644
--- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx
+++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx
@@ -230,7 +230,7 @@ export default function TabGroupPanel({
user can only drag from the tiny left-sidebar header strip. */}
{/* Why: Electron's native drag hit-test only respects no-drag on DOM
diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx
index ae53fc3dd7e..db37a19d241 100644
--- a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx
+++ b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx
@@ -263,10 +263,7 @@ export default function TabGroupSplitLayout({
ref={dragSplit.setDragRootNode}
className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden border-l border-border"
>
-
+
{
+ it('routes the TaskPage GitHub create path through background creation first', () => {
+ const section = sourceBetween(
+ TASK_PAGE_SOURCE,
+ 'const handleUseWorkItem = useCallback(',
+ 'const handleOpenOrUseGitHubWorkItem = useCallback('
+ )
+
+ expect(section).toContain('createGitHubWorkItemWorkspaceInBackground({')
+ expect(section).toContain('openModalFallback: () => openComposerForItem(item)')
+ expect(section).not.toContain("openModal('new-workspace-composer'")
+ })
+
+ it('keeps project-view GitHub actions on the direct start-work path for issue #4756', () => {
+ const section = sourceBetween(
+ PROJECT_VIEW_SOURCE,
+ '// Why: issue #4756 keeps project-view actions on the direct',
+ 'openModalFallback: () => {'
+ )
+
+ expect(PROJECT_VIEW_SOURCE).toContain('issue #4756')
+ expect(section).toContain('void launchWorkItemDirect({')
+ expect(section).toContain("launchSource: 'task_page'")
+ expect(section).not.toContain('createGitHubWorkItemWorkspaceInBackground')
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
index d49b48c0fa7..7d1637545d4 100644
--- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
@@ -111,6 +111,7 @@ import { keybindingMatchesAction } from '../../../../shared/keybindings'
import { pasteTerminalClipboard } from './terminal-clipboard-paste'
import { scheduleImagePasteWebglAtlasRecovery } from './terminal-webgl-paste-recovery'
import { restoreTerminalFitToDesktop, restoreTerminalFitsToDesktop } from './terminal-fit-restore'
+import { useVisibleTerminalWorktreeClaim } from './use-visible-terminal-worktree-claim'
// Why: registry lives in a leaf module so the store slice can import it
// without re-entering the `slice → TerminalPane → store → slice` cycle
@@ -139,6 +140,13 @@ import {
import { getCachedTerminalTabForWorktree } from './terminal-tab-lookup'
import { getCachedTerminalGroupIdForWorktree } from './terminal-unified-tab-lookup'
import { useRepoById } from '@/store/selectors'
+import {
+ isXtermHelperTextarea,
+ releaseTerminalFocusForOutsidePointerDown,
+ releaseTerminalFocusForWindowBlur,
+ resyncTerminalFocusForWindowFocus,
+ setRegularTerminalInputFocusAttribute
+} from './regular-terminal-focus-ownership'
type TerminalPaneProps = {
tabId: string
@@ -193,10 +201,6 @@ function formatClipboardImagePasteError(error: unknown): string {
return `Image paste failed: ${detail}`
}
-function isXtermHelperTextarea(target: EventTarget | null): target is HTMLElement {
- return target instanceof HTMLElement && target.classList.contains('xterm-helper-textarea')
-}
-
function arePaneTitleOverlayRectsEqual(
a: Record,
b: Record
@@ -261,6 +265,8 @@ export default function TerminalPane({
const isVisibleRef = useRef(isVisible)
isVisibleRef.current = isVisible
+ useVisibleTerminalWorktreeClaim({ isVisible, worktreeId })
+
const [expandedPaneId, setExpandedPaneId] = useState(null)
// Why: tracked in React state (not derived from managerRef.getPanes().length)
// so the render containing the portal map (which reads the imperative pane
@@ -1292,7 +1298,7 @@ export default function TerminalPane({
}
}, [consumePendingCodexPaneRestart, handleRestartCodexPane, pendingCodexPaneRestartIds])
- useTerminalFontZoom({ isActive, managerRef, paneFontSizesRef, settingsRef })
+ useTerminalFontZoom({ isActive, containerRef, managerRef, paneFontSizesRef, settingsRef })
useTerminalKeyboardShortcuts({
tabId,
@@ -1410,7 +1416,10 @@ export default function TerminalPane({
if (!container) {
return
}
+ let ownsRegularTerminalFocus = false
const syncFocused = (focused: boolean): void => {
+ ownsRegularTerminalFocus = focused
+ setRegularTerminalInputFocusAttribute(focused)
window.api.ui.setTerminalInputFocused?.(focused)
}
const onFocusIn = (event: FocusEvent): void => {
@@ -1427,6 +1436,32 @@ export default function TerminalPane({
}
syncFocused(false)
}
+ const onPointerDown = (event: PointerEvent): void => {
+ releaseTerminalFocusForOutsidePointerDown({
+ container,
+ activeElement: document.activeElement,
+ pointerTarget: event.target,
+ syncFocused
+ })
+ }
+ const onWindowBlur = (): void => {
+ // Why: webview/browser handoff leaves the helper textarea as DOM focus,
+ // so clear only the main-process mirror and let guest focus proceed.
+ releaseTerminalFocusForWindowBlur({
+ container,
+ activeElement: document.activeElement,
+ syncFocused
+ })
+ }
+ const onWindowFocus = (): void => {
+ // Why: app reactivation can preserve DOM focus on xterm after blur
+ // cleared the process-wide shortcut mirror.
+ resyncTerminalFocusForWindowFocus({
+ container,
+ activeElement: document.activeElement,
+ syncFocused
+ })
+ }
if (
isXtermHelperTextarea(document.activeElement) &&
@@ -1436,13 +1471,18 @@ export default function TerminalPane({
}
container.addEventListener('focusin', onFocusIn)
container.addEventListener('focusout', onFocusOut)
+ document.addEventListener('pointerdown', onPointerDown, true)
+ window.addEventListener('blur', onWindowBlur)
+ window.addEventListener('focus', onWindowFocus)
return () => {
container.removeEventListener('focusin', onFocusIn)
container.removeEventListener('focusout', onFocusOut)
- if (
- isXtermHelperTextarea(document.activeElement) &&
- container.contains(document.activeElement)
- ) {
+ document.removeEventListener('pointerdown', onPointerDown, true)
+ window.removeEventListener('blur', onWindowBlur)
+ window.removeEventListener('focus', onWindowFocus)
+ // Why: the helper textarea may be removed before cleanup observes
+ // document.activeElement, so clear by this pane's mirrored ownership.
+ if (ownsRegularTerminalFocus) {
syncFocused(false)
}
}
@@ -1760,27 +1800,24 @@ export default function TerminalPane({
return subscribeTerminalPaneAttention(tabId, applyTerminalPaneAttention)
}, [tabId, paneCount, applyTerminalPaneAttention])
- // Sync the pane title reservation before paint. The title UI stays outside
- // xterm's DOM, but xterm must still fit below it so the first terminal row is
- // never hidden under the title strip.
+ // Sync the pane title reservation before paint. Text/banner chrome stays
+ // outside xterm's DOM, but xterm must still fit below it so the first
+ // terminal row is never hidden under meaningful status UI.
useLayoutEffect(() => {
const manager = managerRef.current
if (!manager) {
return
}
- // Show the title bar space when the pane has a title, is being
- // inline-edited, or has transient startup chrome. Unread activity does
- // not reserve this space; its overlay should not reflow terminal content.
+ // Show title space only for text/status chrome. Chromeless pane controls
+ // float over xterm so untitled panes keep their first row.
const needsFit = syncSessionRestoredBannerTitleSpace({
panes: manager.getPanes(),
paneTitles,
renamingPaneId,
- sessionRestoredBannerPaneIds,
- reservePaneHeaderSpace: isActive && (isVisible || shouldMeasureHiddenStartup)
+ sessionRestoredBannerPaneIds
})
if (needsFit && (isVisible || shouldMeasureHiddenStartup)) {
- // Why: inactive terminal tabs can drop active-only header reservation.
- // Fitting that hidden geometry changes PTY rows and wakes TUIs with
+ // Why: fitting hidden geometry changes PTY rows and wakes TUIs with
// SIGWINCH; the visible resume path owns any real layout correction.
fitPanes(manager)
}
@@ -1790,7 +1827,6 @@ export default function TerminalPane({
paneTitles,
renamingPaneId,
sessionRestoredBannerPaneIds,
- isActive,
isVisible,
shouldMeasureHiddenStartup
])
diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx
index 6178db32516..78e80b3231e 100644
--- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx
@@ -127,11 +127,13 @@ describe('TerminalPaneHeaderOverlay', () => {
expect(onClosePane).not.toHaveBeenCalledWith(1)
})
- it('keeps close-pane available for untitled split pane headers', () => {
+ it('keeps split and close-pane controls available for untitled split pane headers', () => {
const { container, onClosePane, onRemoveTitle } = renderOverlay({
paneTitles: { 1: '', 2: '' }
})
+ expect(container.querySelector('button[aria-label="Split Terminal Right"]')).not.toBeNull()
+ expect(container.querySelector('.pane-title-drag-handle')).toBeNull()
const closePane = container.querySelector('button[aria-label="Close Pane"]')
expect(closePane).not.toBeNull()
diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx
index ca10b1ac38b..2aa1d729745 100644
--- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx
@@ -182,7 +182,7 @@ export default function TerminalPaneHeaderOverlay({
/>
) : (
<>
- {paneCount > 1 && (
+ {paneCount > 1 && !isChromeless && (
) : null}
-
+
{showAlwaysOnHeaders ? (
diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts
index 7ea67c0d99b..ebedf8bed89 100644
--- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts
+++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts
@@ -21,6 +21,11 @@ import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
import { useAppStore } from '@/store'
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
+import {
+ markTerminalFollowOutput,
+ markTerminalPinnedViewport,
+ syncTerminalScrollIntentFromViewport
+} from '@/lib/pane-manager/terminal-scroll-intent'
export function recordKeyboardCreatedTerminalPaneSplit(
createdPane: unknown,
@@ -314,9 +319,13 @@ export function useTerminalKeyboardShortcuts({
return
}
if (action.position === 'top') {
+ markTerminalPinnedViewport(pane.terminal)
pane.terminal.scrollToLine(0)
+ syncTerminalScrollIntentFromViewport(pane.terminal)
} else {
+ markTerminalFollowOutput(pane.terminal)
pane.terminal.scrollToBottom()
+ syncTerminalScrollIntentFromViewport(pane.terminal)
}
return
}
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
index da2705e5288..c0d45d84249 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
@@ -6255,6 +6255,12 @@ describe('connectPanePty', () => {
const pane = createPane(1)
pane.terminal.buffer.active.viewportY = 42
pane.terminal.buffer.active.baseY = 100
+ pane.terminal.write.mockImplementation((data: string, callback?: () => void) => {
+ if (data.includes('snapshot-state')) {
+ pane.terminal.buffer.active.viewportY = 0
+ }
+ callback?.()
+ })
const manager = createManager(1)
const deps = createDeps({
isVisibleRef: { current: false }
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts
index 4133374fa2d..7277541f562 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
@@ -58,8 +58,11 @@ import {
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
import { recordAgentHibernationPaneOutput } from '@/lib/agent-hibernation-output-activity'
import { isLocalNativeWindowsConpty } from '@/lib/pane-manager/windows-pty-compatibility'
-import { recordTerminalOutput, restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll'
-import type { ScrollState } from '@/lib/pane-manager/pane-manager-types'
+import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll'
+import {
+ captureTerminalWriteScrollIntent,
+ enforceTerminalWriteScrollIntent
+} from '@/lib/pane-manager/terminal-scroll-intent'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id'
import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
@@ -3114,40 +3117,13 @@ export function connectPanePty(
})
}
- function captureScrollStateForSnapshotReplay(): ScrollState | null {
- const buf = pane.terminal.buffer?.active
- if (!buf) {
- return null
- }
- const viewportY = buf.viewportY
- const baseY = buf.baseY
- if (!Number.isFinite(viewportY) || !Number.isFinite(baseY)) {
- return null
- }
- return {
- bufferType: buf.type,
- wasAtBottom: viewportY >= baseY,
- viewportY,
- baseY
- }
- }
-
- function restoreScrollStateAfterSnapshotReplay(state: ScrollState | null): void {
- if (!state || state.wasAtBottom) {
- return
- }
- // Why: hidden-backlog replay clears xterm after visibility scroll restore;
- // re-apply a scrolled-up viewport so recovery does not jump to bottom.
- restoreScrollStateAfterLayout(pane.terminal, state)
- }
-
function applyMainBufferSnapshot(snapshot: {
data: string
cols: number
rows: number
seq?: number
}): void {
- const scrollState = captureScrollStateForSnapshotReplay()
+ const scrollIntent = captureTerminalWriteScrollIntent(pane.terminal)
const colsBeforeReplay = pane.terminal.cols
const rowsBeforeReplay = pane.terminal.rows
const hasSnapshotDimensions =
@@ -3191,7 +3167,9 @@ export function connectPanePty(
}
scheduleReattachIdleAgentCursorReset()
}
- restoreScrollStateAfterSnapshotReplay(scrollState)
+ // Why: snapshot replay clears and rebuilds xterm state; re-apply the
+ // user's scroll intent once so hidden catch-up cannot repin the viewport.
+ enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent)
}
function requestHiddenOutputRestoreIfNeeded(opts?: { bypassScheduler?: boolean }): boolean {
diff --git a/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts b/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts
new file mode 100644
index 00000000000..e76015eefdf
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts
@@ -0,0 +1,153 @@
+// @vitest-environment happy-dom
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ REGULAR_TERMINAL_INPUT_FOCUSED_ATTRIBUTE,
+ getPaneOwnedActiveHelperTextarea,
+ releaseTerminalFocusForOutsidePointerDown,
+ releaseTerminalFocusForWindowBlur,
+ resyncTerminalFocusForWindowFocus,
+ setRegularTerminalInputFocusAttribute
+} from './regular-terminal-focus-ownership'
+
+function appendPane(): HTMLDivElement {
+ const pane = document.createElement('div')
+ document.body.appendChild(pane)
+ return pane
+}
+
+function appendHelper(pane: HTMLElement): HTMLTextAreaElement {
+ const helper = document.createElement('textarea')
+ helper.className = 'xterm-helper-textarea'
+ pane.appendChild(helper)
+ return helper
+}
+
+describe('regular terminal focus ownership', () => {
+ beforeEach(() => {
+ document.body.replaceChildren()
+ document.documentElement.removeAttribute(REGULAR_TERMINAL_INPUT_FOCUSED_ATTRIBUTE)
+ })
+
+ it('releases and blurs the owning helper textarea on outside pointerdown', () => {
+ const pane = appendPane()
+ const helper = appendHelper(pane)
+ const outside = document.createElement('button')
+ const syncFocused = vi.fn()
+ const blur = vi.spyOn(helper, 'blur')
+ document.body.appendChild(outside)
+ helper.focus()
+
+ const released = releaseTerminalFocusForOutsidePointerDown({
+ container: pane,
+ activeElement: document.activeElement,
+ pointerTarget: outside,
+ syncFocused
+ })
+
+ expect(released).toBe(true)
+ expect(syncFocused).toHaveBeenCalledWith(false)
+ expect(blur).toHaveBeenCalledOnce()
+ })
+
+ it('keeps ownership for pointerdowns inside the same terminal pane', () => {
+ const pane = appendPane()
+ const helper = appendHelper(pane)
+ const innerTarget = document.createElement('div')
+ const syncFocused = vi.fn()
+ const blur = vi.spyOn(helper, 'blur')
+ pane.appendChild(innerTarget)
+ helper.focus()
+
+ const released = releaseTerminalFocusForOutsidePointerDown({
+ container: pane,
+ activeElement: document.activeElement,
+ pointerTarget: innerTarget,
+ syncFocused
+ })
+
+ expect(released).toBe(false)
+ expect(syncFocused).not.toHaveBeenCalled()
+ expect(blur).not.toHaveBeenCalled()
+ })
+
+ it("does not clear another pane's active helper ownership", () => {
+ const pane = appendPane()
+ const otherPane = appendPane()
+ const otherHelper = appendHelper(otherPane)
+ const outside = document.createElement('button')
+ const syncFocused = vi.fn()
+ document.body.appendChild(outside)
+ otherHelper.focus()
+
+ const released = releaseTerminalFocusForOutsidePointerDown({
+ container: pane,
+ activeElement: document.activeElement,
+ pointerTarget: outside,
+ syncFocused
+ })
+
+ expect(released).toBe(false)
+ expect(syncFocused).not.toHaveBeenCalled()
+ expect(document.activeElement).toBe(otherHelper)
+ })
+
+ it('releases the main-process mirror on renderer blur without blurring DOM focus', () => {
+ const pane = appendPane()
+ const helper = appendHelper(pane)
+ const syncFocused = vi.fn()
+ const blur = vi.spyOn(helper, 'blur')
+ helper.focus()
+
+ const released = releaseTerminalFocusForWindowBlur({
+ container: pane,
+ activeElement: document.activeElement,
+ syncFocused
+ })
+
+ expect(released).toBe(true)
+ expect(syncFocused).toHaveBeenCalledWith(false)
+ expect(blur).not.toHaveBeenCalled()
+ expect(document.activeElement).toBe(helper)
+ })
+
+ it('resyncs terminal ownership on renderer focus when the same helper remains active', () => {
+ const pane = appendPane()
+ const helper = appendHelper(pane)
+ const syncFocused = vi.fn()
+ helper.focus()
+
+ const synced = resyncTerminalFocusForWindowFocus({
+ container: pane,
+ activeElement: document.activeElement,
+ syncFocused
+ })
+
+ expect(synced).toBe(true)
+ expect(syncFocused).toHaveBeenCalledWith(true)
+ })
+
+ it('resolves an owned active xterm helper textarea', () => {
+ const pane = appendPane()
+ const helper = appendHelper(pane)
+ const otherPane = appendPane()
+ const button = document.createElement('button')
+ document.body.appendChild(button)
+
+ expect(getPaneOwnedActiveHelperTextarea(pane, helper)).toBe(helper)
+ expect(getPaneOwnedActiveHelperTextarea(pane, button)).toBeNull()
+ expect(getPaneOwnedActiveHelperTextarea(otherPane, helper)).toBeNull()
+ expect(getPaneOwnedActiveHelperTextarea(pane, null)).toBeNull()
+ })
+
+ it('tracks regular terminal focus on the document element for titlebar click release', () => {
+ setRegularTerminalInputFocusAttribute(true)
+ expect(document.documentElement.hasAttribute(REGULAR_TERMINAL_INPUT_FOCUSED_ATTRIBUTE)).toBe(
+ true
+ )
+
+ setRegularTerminalInputFocusAttribute(false)
+ expect(document.documentElement.hasAttribute(REGULAR_TERMINAL_INPUT_FOCUSED_ATTRIBUTE)).toBe(
+ false
+ )
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.ts b/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.ts
new file mode 100644
index 00000000000..d79226518c6
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.ts
@@ -0,0 +1,73 @@
+export type TerminalInputFocusSync = (focused: boolean) => void
+export const REGULAR_TERMINAL_INPUT_FOCUSED_ATTRIBUTE = 'data-regular-terminal-input-focused'
+
+export function isXtermHelperTextarea(target: EventTarget | null): target is HTMLElement {
+ return target instanceof HTMLElement && target.classList.contains('xterm-helper-textarea')
+}
+
+export function setRegularTerminalInputFocusAttribute(focused: boolean): void {
+ if (typeof document === 'undefined') {
+ return
+ }
+ document.documentElement.toggleAttribute(REGULAR_TERMINAL_INPUT_FOCUSED_ATTRIBUTE, focused)
+}
+
+export function getPaneOwnedActiveHelperTextarea(
+ container: HTMLElement,
+ activeElement: Element | null
+): HTMLElement | null {
+ if (!isXtermHelperTextarea(activeElement) || !container.contains(activeElement)) {
+ return null
+ }
+ return activeElement
+}
+
+export function releaseTerminalFocusForOutsidePointerDown(args: {
+ container: HTMLElement
+ activeElement: Element | null
+ pointerTarget: EventTarget | null
+ syncFocused: TerminalInputFocusSync
+}): boolean {
+ const activeHelper = getPaneOwnedActiveHelperTextarea(args.container, args.activeElement)
+ if (!activeHelper) {
+ return false
+ }
+
+ if (isNode(args.pointerTarget) && args.container.contains(args.pointerTarget)) {
+ return false
+ }
+
+ args.syncFocused(false)
+ activeHelper.blur()
+ return true
+}
+
+export function releaseTerminalFocusForWindowBlur(args: {
+ container: HTMLElement
+ activeElement: Element | null
+ syncFocused: TerminalInputFocusSync
+}): boolean {
+ if (!getPaneOwnedActiveHelperTextarea(args.container, args.activeElement)) {
+ return false
+ }
+
+ args.syncFocused(false)
+ return true
+}
+
+export function resyncTerminalFocusForWindowFocus(args: {
+ container: HTMLElement
+ activeElement: Element | null
+ syncFocused: TerminalInputFocusSync
+}): boolean {
+ if (!getPaneOwnedActiveHelperTextarea(args.container, args.activeElement)) {
+ return false
+ }
+
+ args.syncFocused(true)
+ return true
+}
+
+function isNode(value: EventTarget | null): value is Node {
+ return typeof Node !== 'undefined' && value instanceof Node
+}
diff --git a/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx b/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx
index 1683b4fa5e5..da560dc16fb 100644
--- a/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx
+++ b/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx
@@ -76,7 +76,7 @@ describe('session restored banner pane state', () => {
expect(paneText(createdPane)).toBe(SESSION_RESTORED_BANNER_TEXT)
})
- it('reserves title space for always-on pane headers on the active tab', () => {
+ it('does not reserve title space for chromeless always-on pane headers', () => {
const activePane = createPane(1)
const secondPane = createPane(2)
@@ -84,13 +84,28 @@ describe('session restored banner pane state', () => {
panes: [activePane, secondPane],
paneTitles: {},
renamingPaneId: null,
- sessionRestoredBannerPaneIds: new Set(),
- reservePaneHeaderSpace: true
+ sessionRestoredBannerPaneIds: new Set()
+ })
+
+ expect(needsFit).toBe(false)
+ expect(activePane.container.hasAttribute('data-has-title')).toBe(false)
+ expect(secondPane.container.hasAttribute('data-has-title')).toBe(false)
+ })
+
+ it('reserves title space for explicit titles and inline rename', () => {
+ const titledPane = createPane(1)
+ const renamingPane = createPane(2)
+
+ const needsFit = syncSessionRestoredBannerTitleSpace({
+ panes: [titledPane, renamingPane],
+ paneTitles: { [titledPane.id]: 'server' },
+ renamingPaneId: renamingPane.id,
+ sessionRestoredBannerPaneIds: new Set()
})
expect(needsFit).toBe(true)
- expect(activePane.container.hasAttribute('data-has-title')).toBe(true)
- expect(secondPane.container.hasAttribute('data-has-title')).toBe(true)
+ expect(titledPane.container.hasAttribute('data-has-title')).toBe(true)
+ expect(renamingPane.container.hasAttribute('data-has-title')).toBe(true)
})
it('renders and reserves title space only on the restored inactive split pane', async () => {
diff --git a/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.ts b/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.ts
index 969225d8365..03fd87ad600 100644
--- a/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.ts
+++ b/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.ts
@@ -88,12 +88,10 @@ export function syncSessionRestoredBannerTitleSpace(args: {
paneTitles: Readonly>
renamingPaneId: number | null
sessionRestoredBannerPaneIds: ReadonlySet
- reservePaneHeaderSpace?: boolean
}): boolean {
let needsFit = false
for (const pane of args.panes) {
const shouldShow =
- args.reservePaneHeaderSpace === true ||
!!args.paneTitles[pane.id] ||
args.renamingPaneId === pane.id ||
args.sessionRestoredBannerPaneIds.has(pane.id)
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 87db96c3a22..5feee00f707 100644
--- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts
@@ -5,7 +5,7 @@ import {
flushTerminalOutput,
requestTerminalBacklogRecovery
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
-import { restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll'
+import { enforceTerminalCurrentScrollIntent } from '@/lib/pane-manager/terminal-scroll-intent'
import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers'
const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024
@@ -47,7 +47,7 @@ export function resumeTerminalVisibility({
// post-resume fit runs. Capture numeric viewport positions first; the
// restore path avoids content matching so duplicate agent log lines do
// not jump to the wrong history entry.
- const viewportPositions = captureViewportPositions(!wasVisible)
+ captureViewportPositions(!wasVisible)
withSuppressedScrollTracking(() => {
if (shouldUseLightTabResume) {
// Why: intra-worktree tab switches only toggle the overlay. Keeping
@@ -61,7 +61,7 @@ export function resumeTerminalVisibility({
} else {
resumeTerminalVisibilityHeavy(manager, isActive)
}
- restoreTerminalViewportPositions(manager, viewportPositions)
+ enforceTerminalViewportIntents(manager)
if (!shouldUseLightTabResume) {
// Why: this clear wipes the glyph atlas shared with other same-config
// terminals; the global reset rebuilds their render models too.
@@ -137,14 +137,8 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean):
}
}
-function restoreTerminalViewportPositions(
- manager: PaneManager,
- viewportPositions: Map
-): void {
+function enforceTerminalViewportIntents(manager: PaneManager): void {
for (const pane of manager.getPanes()) {
- const position = viewportPositions.get(pane.id)
- if (position) {
- restoreScrollStateAfterLayout(pane.terminal, position)
- }
+ enforceTerminalCurrentScrollIntent(pane.terminal)
}
}
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
index df9f4ba45fc..75245c1dddd 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({
flushTerminalOutput: vi.fn(),
getTerminalOutputEpoch: vi.fn(() => 0),
handleTerminalFileDrop: vi.fn(),
+ enforceTerminalCurrentScrollIntent: vi.fn(),
pasteTerminalText: vi.fn(),
recordTerminalUserInputForLeaf: vi.fn(),
requestTerminalBacklogRecovery: vi.fn(),
@@ -75,6 +76,10 @@ vi.mock('@/lib/pane-manager/pane-scroll', () => ({
restoreScrollStateAfterLayout: mocks.restoreScrollStateAfterLayout
}))
+vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({
+ enforceTerminalCurrentScrollIntent: mocks.enforceTerminalCurrentScrollIntent
+}))
+
vi.mock('./terminal-drop-handler', () => ({
handleTerminalFileDrop: mocks.handleTerminalFileDrop
}))
@@ -234,6 +239,9 @@ describe('useTerminalPaneGlobalEffects', () => {
mocks.restoreScrollStateAfterLayout.mockImplementation((terminal: { name: string }) => {
order.push(`restore:${terminal.name}`)
})
+ mocks.enforceTerminalCurrentScrollIntent.mockImplementation((terminal: { name: string }) => {
+ order.push(`intent:${terminal.name}`)
+ })
mocks.fitAndFocusPanes.mockImplementation(() => order.push('fit-focus'))
// Why: the resume path resets atlases through the live-manager registry
@@ -267,10 +275,11 @@ describe('useTerminalPaneGlobalEffects', () => {
'flush:terminal-b',
'resume',
'fit-focus',
- 'restore:terminal-a',
- 'restore:terminal-b',
+ 'intent:terminal-a',
+ 'intent:terminal-b',
'reset-atlas'
])
+ expect(mocks.restoreScrollStateAfterLayout).not.toHaveBeenCalled()
expect(mocks.flushTerminalOutput).toHaveBeenNthCalledWith(1, terminalA, {
maxChars: 256 * 1024
})
@@ -563,7 +572,7 @@ describe('useTerminalPaneGlobalEffects', () => {
expect(window.api.pty.setActiveRendererPty).toHaveBeenCalledWith('pty-active', true)
})
- it('restores from the pre-hide scroll state when hidden layout changes the viewport', () => {
+ it('enforces scroll intent after hidden layout changes the viewport', () => {
const terminalA = { name: 'terminal-a' }
const manager = {
getPanes: vi.fn(() => [{ id: 1, terminal: terminalA }]),
@@ -618,7 +627,8 @@ describe('useTerminalPaneGlobalEffects', () => {
expect(mocks.captureScrollState).toHaveBeenCalledTimes(2)
expect(manager.suspendRendering).toHaveBeenCalledTimes(1)
- expect(mocks.restoreScrollStateAfterLayout).toHaveBeenLastCalledWith(terminalA, preHideState)
+ expect(mocks.restoreScrollStateAfterLayout).not.toHaveBeenCalled()
+ expect(mocks.enforceTerminalCurrentScrollIntent).toHaveBeenLastCalledWith(terminalA)
})
it('clears WebGL texture atlases when the active visible terminal regains focus', () => {
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
index cfd5b6f398a..f26d9adc198 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
@@ -71,6 +71,10 @@ import { getConnectionId } from '@/lib/connection-context'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard'
import { fitAndFocusPanes, fitPanes } from './pane-helpers'
+import {
+ markTerminalPinnedViewport,
+ syncTerminalScrollIntentSoon
+} from '@/lib/pane-manager/terminal-scroll-intent'
import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { e2eConfig } from '@/lib/e2e-config'
import {
@@ -720,6 +724,15 @@ export function useTerminalPaneLifecycle({
return false
}
+ if (e.type === 'keydown') {
+ if (e.key === 'PageUp' || e.key === 'Home') {
+ markTerminalPinnedViewport(pane.terminal)
+ syncTerminalScrollIntentSoon(pane.terminal, { preservePinnedAtBottom: true })
+ } else if (e.key === 'PageDown' || e.key === 'End') {
+ syncTerminalScrollIntentSoon(pane.terminal)
+ }
+ }
+
return !shouldBypassXtermKeyboardEvent(e, {
isMac,
hasSelection: pane.terminal.hasSelection()
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts
index c30d96a5387..3cd22968f4b 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts
@@ -11,7 +11,9 @@ const mocks = vi.hoisted(() => ({
baseY: 0
})),
flushTerminalOutput: vi.fn(),
- getTerminalOutputEpoch: vi.fn(() => 1)
+ getTerminalOutputEpoch: vi.fn(() => 1),
+ getTerminalScrollIntentKind: vi.fn(() => 'followOutput'),
+ markTerminalFollowOutput: vi.fn()
}))
const reactRefState = vi.hoisted(() => ({
@@ -68,6 +70,11 @@ vi.mock('@/lib/pane-manager/pane-scroll', () => ({
getTerminalOutputEpoch: mocks.getTerminalOutputEpoch
}))
+vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({
+ getTerminalScrollIntentKind: mocks.getTerminalScrollIntentKind,
+ markTerminalFollowOutput: mocks.markTerminalFollowOutput
+}))
+
describe('useTerminalScrollVisibilityMemory', () => {
const originalRequestAnimationFrame = globalThis.requestAnimationFrame
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame
@@ -75,6 +82,7 @@ describe('useTerminalScrollVisibilityMemory', () => {
beforeEach(() => {
resetHookRefs()
vi.clearAllMocks()
+ mocks.getTerminalScrollIntentKind.mockReturnValue('followOutput')
})
afterEach(() => {
@@ -121,6 +129,42 @@ describe('useTerminalScrollVisibilityMemory', () => {
maxChars: 256 * 1024
})
expect(terminal.scrollToBottom).toHaveBeenCalled()
+ expect(mocks.markTerminalFollowOutput).toHaveBeenCalledWith(terminal)
+ })
+
+ it('does not turn a pinned viewport into follow-output when pending focus requests catch up', () => {
+ mocks.getTerminalScrollIntentKind.mockReturnValue('pinnedViewport')
+ const terminal = {
+ onScroll: vi.fn(() => ({ dispose: vi.fn() })),
+ scrollToBottom: vi.fn()
+ }
+ const manager = {
+ getPanes: vi.fn(() => [{ id: 1, terminal }])
+ }
+ const animationFrames: FrameRequestCallback[] = []
+ globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
+ animationFrames.push(callback)
+ return animationFrames.length
+ })
+
+ beginHookRender()
+ const visibilityMemory = useTerminalScrollVisibilityMemory({
+ managerRef: { current: manager as never },
+ isVisibleRef: { current: true },
+ visibleResumeCompleteRef: { current: true },
+ paneCount: 1
+ })
+
+ visibilityMemory.scheduleFollowOutputIfNeeded(1)
+ animationFrames.shift()?.(16)
+ animationFrames.shift()?.(32)
+
+ expect(mocks.flushTerminalOutput).toHaveBeenCalledWith(terminal, {
+ maxChars: 256 * 1024
+ })
+ expect(mocks.cancelDeferredScrollRestore).not.toHaveBeenCalled()
+ expect(mocks.markTerminalFollowOutput).not.toHaveBeenCalled()
+ expect(terminal.scrollToBottom).not.toHaveBeenCalled()
})
it('cancels pending follow-output frames on cleanup', () => {
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts
index 182ef2741e9..05afb66d5d4 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts
@@ -1,11 +1,15 @@
import { useCallback, useEffect, useRef } from 'react'
-import type { IDisposable, Terminal } from '@xterm/xterm'
+import type { Terminal } from '@xterm/xterm'
import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
import {
cancelDeferredScrollRestore,
captureScrollState,
getTerminalOutputEpoch
} from '@/lib/pane-manager/pane-scroll'
+import {
+ getTerminalScrollIntentKind,
+ markTerminalFollowOutput
+} from '@/lib/pane-manager/terminal-scroll-intent'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { ScrollState } from '@/lib/pane-manager/pane-manager-types'
@@ -37,7 +41,6 @@ export function useTerminalScrollVisibilityMemory({
paneCount
}: UseTerminalScrollVisibilityMemoryArgs): TerminalScrollVisibilityMemory {
const visibleScrollSnapshotsRef = useRef