+}): { key: LinearIssueFilterSectionKey; label: string; value: string }[] {
+ const canonical = canonicalizeLinearIssueAttributeFilter(options.value)
+ const pills: { key: LinearIssueFilterSectionKey; label: string; value: string }[] = []
+ if (canonical.stateIds.length > 0) {
+ pills.push({
+ key: 'status',
+ label: translate('auto.components.linear-issue-attribute-filter-sections.status', 'Status'),
+ value: canonical.stateIds.map((id) => options.stateNamesById.get(id) ?? id).join(', ')
+ })
+ }
+ if (canonical.priorities.length > 0) {
+ pills.push({
+ key: 'priority',
+ label: translate(
+ 'auto.components.linear-issue-attribute-filter-sections.priority',
+ 'Priority'
+ ),
+ value: canonical.priorities.map((p) => getLinearPriorityLabel(p)).join(', ')
+ })
+ }
+ if (canonical.assignee?.kind === 'unassigned') {
+ pills.push({
+ key: 'assignee',
+ label: translate(
+ 'auto.components.linear-issue-attribute-filter-sections.assignee',
+ 'Assignee'
+ ),
+ value: translate(
+ 'auto.components.linear-issue-attribute-filter-sections.unassigned',
+ 'Unassigned'
+ )
+ })
+ } else if (canonical.assignee?.kind === 'user') {
+ pills.push({
+ key: 'assignee',
+ label: translate(
+ 'auto.components.linear-issue-attribute-filter-sections.assignee',
+ 'Assignee'
+ ),
+ value: options.memberNamesById.get(canonical.assignee.id) ?? canonical.assignee.id
+ })
+ }
+ if (canonical.labelIds.length > 0) {
+ pills.push({
+ key: 'labels',
+ label: translate('auto.components.linear-issue-attribute-filter-sections.labels', 'Labels'),
+ value: canonical.labelIds.map((id) => options.labelNamesById.get(id) ?? id).join(', ')
+ })
+ }
+ return pills
+}
+
+function priorityOptions(): PickerOption[] {
+ return [0, 1, 2, 3, 4].map((priority) => ({
+ key: String(priority),
+ primary: getLinearPriorityLabel(priority)
+ }))
+}
+
+export function LinearIssueFilterSectionMenu({
+ value,
+ onOpenSection
+}: {
+ value: LinearIssueAttributeFilter
+ onOpenSection: (section: LinearIssueFilterSectionKey) => void
+}): React.JSX.Element {
+ const sections: { key: LinearIssueFilterSectionKey; label: string; summary: string }[] = [
+ {
+ key: 'status',
+ label: translate('auto.components.linear-issue-attribute-filter-sections.status', 'Status'),
+ summary:
+ value.stateIds.length > 0
+ ? translate(
+ 'auto.components.linear-issue-attribute-filter-sections.countSelected',
+ '{{count}} selected',
+ { count: value.stateIds.length }
+ )
+ : ''
+ },
+ {
+ key: 'priority',
+ label: translate(
+ 'auto.components.linear-issue-attribute-filter-sections.priority',
+ 'Priority'
+ ),
+ summary:
+ value.priorities.length > 0
+ ? translate(
+ 'auto.components.linear-issue-attribute-filter-sections.countSelected',
+ '{{count}} selected',
+ { count: value.priorities.length }
+ )
+ : ''
+ },
+ {
+ key: 'assignee',
+ label: translate(
+ 'auto.components.linear-issue-attribute-filter-sections.assignee',
+ 'Assignee'
+ ),
+ summary: value.assignee
+ ? value.assignee.kind === 'unassigned'
+ ? translate(
+ 'auto.components.linear-issue-attribute-filter-sections.unassigned',
+ 'Unassigned'
+ )
+ : translate('auto.components.linear-issue-attribute-filter-sections.selected', 'selected')
+ : ''
+ },
+ {
+ key: 'labels',
+ label: translate('auto.components.linear-issue-attribute-filter-sections.labels', 'Labels'),
+ summary:
+ value.labelIds.length > 0
+ ? translate(
+ 'auto.components.linear-issue-attribute-filter-sections.countSelected',
+ '{{count}} selected',
+ { count: value.labelIds.length }
+ )
+ : ''
+ }
+ ]
+
+ return (
+
+ {sections.map((section) => (
+ onOpenSection(section.key)}
+ className="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left transition hover:bg-muted/50"
+ >
+ {section.label}
+
+ {section.summary ? (
+ {section.summary}
+ ) : null}
+
+
+
+ ))}
+
+ )
+}
+
+export function LinearIssueFilterSectionDetail({
+ section,
+ value,
+ onChange,
+ statusOptions,
+ assigneeOptions,
+ labelOptions,
+ statusLoading,
+ statusError,
+ assigneeLoading,
+ assigneeError,
+ labelLoading,
+ labelError,
+ teamRequiredMessage,
+ onBack
+}: {
+ section: LinearIssueFilterSectionKey
+ value: LinearIssueAttributeFilter
+ onChange: (next: LinearIssueAttributeFilter) => void
+ statusOptions: PickerOption[]
+ assigneeOptions: PickerOption[]
+ labelOptions: PickerOption[]
+ statusLoading: boolean
+ statusError: string | null
+ assigneeLoading: boolean
+ assigneeError: string | null
+ labelLoading: boolean
+ labelError: string | null
+ teamRequiredMessage: string | null
+ onBack: () => void
+}): React.JSX.Element {
+ if (section === 'priority') {
+ return (
+
+
+
+ onChange({
+ ...value,
+ priorities: keys
+ .map((key) => Number.parseInt(key, 10))
+ .filter((n) => Number.isInteger(n) && n >= 0 && n <= 4)
+ })
+ }
+ />
+
+ )
+ }
+
+ if (
+ teamRequiredMessage &&
+ (section === 'status' || section === 'labels' || section === 'assignee')
+ ) {
+ return (
+
+
+ {section === 'assignee' ? (
+
+
+ onChange({
+ ...value,
+ assignee: value.assignee?.kind === 'unassigned' ? null : { kind: 'unassigned' }
+ })
+ }
+ >
+ {translate(
+ 'auto.components.linear-issue-attribute-filter-sections.unassigned',
+ 'Unassigned'
+ )}
+
+
+ ) : null}
+
{teamRequiredMessage}
+
+ )
+ }
+
+ if (section === 'status') {
+ return (
+
+
+ onChange({ ...value, stateIds })}
+ />
+
+ )
+ }
+
+ if (section === 'labels') {
+ return (
+
+
+ onChange({ ...value, labelIds })}
+ />
+
+ )
+ }
+
+ const activeAssignee =
+ value.assignee?.kind === 'unassigned'
+ ? '__unassigned__'
+ : value.assignee?.kind === 'user'
+ ? value.assignee.id
+ : null
+
+ return (
+
+
+ {
+ if (!key) {
+ onChange({ ...value, assignee: null })
+ return
+ }
+ if (key === '__unassigned__') {
+ onChange({ ...value, assignee: { kind: 'unassigned' } })
+ return
+ }
+ onChange({ ...value, assignee: { kind: 'user', id: key } })
+ }}
+ />
+
+ )
+}
+
+function SectionBack({ onBack }: { onBack: () => void }): React.JSX.Element {
+ return (
+
+ {translate('auto.components.linear-issue-attribute-filter-sections.back', 'Back')}
+
+ )
+}
diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
index f508faebd82..c17ea30c173 100644
--- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
+++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
@@ -853,9 +853,10 @@ export default function SmartWorkspaceNameField({
const trimmed = debouncedQuery.trim()
const request = trimmed
? searchLinearIssues(trimmed, RESULT_LIMIT, { sourceContext: linearSourceContext })
- : listLinearIssues('assigned', RESULT_LIMIT, { sourceContext: linearSourceContext }).then(
- (result) => result.items
- )
+ : listLinearIssues(
+ { kind: 'list', filter: 'assigned', limit: RESULT_LIMIT },
+ { sourceContext: linearSourceContext }
+ ).then((result) => result.items)
void request
.then((issues) => {
if (!stale) {
diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx
index aef619545ea..6a863e9a36d 100644
--- a/src/renderer/src/components/right-sidebar/SourceControl.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx
@@ -109,6 +109,7 @@ import {
type SourceControlEntryGroups,
type SourceControlSectionArea
} from './source-control-section-order'
+import { SourceControlVirtualFileList } from './source-control-virtual-file-list'
import {
buildActiveOpenFileSignature,
buildActiveOpenRowKeys
@@ -799,6 +800,10 @@ export function clearRemoteActionErrorsForCompletedConflictOperations({
function SourceControlInner(): React.JSX.Element {
const sourceControlRef = useRef(null)
+ // Why: the changed-file sections virtualize against the panel's shared
+ // scroller rather than owning a nested scroll container. State (not a ref)
+ // so the lists re-render and start observing once the element attaches.
+ const [fileListScrollElement, setFileListScrollElement] = useState(null)
const isMac = useMemo(() => navigator.userAgent.includes('Mac'), [])
const pendingCommentEditorRevealFrameIdsRef = useRef([])
// Why: React setState is async, so a rapid double-click on the Commit
@@ -5708,6 +5713,7 @@ function SourceControlInner(): React.JSX.Element {
)}
0 ? 50 : undefined }}
>
@@ -6042,8 +6048,12 @@ function SourceControlInner(): React.JSX.Element {
}
/>
{!isCollapsed &&
- (sourceControlViewMode === 'tree'
- ? (visibleTreeRowsBySection[id] ?? []).map((node) => {
+ (sourceControlViewMode === 'tree' ? (
+ node.key}
+ renderRow={(node) => {
if (node.type === 'submodule-placeholder') {
return (
)
- })
- : (visibleListRowsBySection[id] ?? []).map((row) => {
+ }}
+ />
+ ) : (
+
+ row.type === 'submodule-placeholder'
+ ? row.key
+ : `${row.entry.area}::${row.entry.path}`
+ }
+ renderRow={(row) => {
if (row.type === 'submodule-placeholder') {
return (
)
- }))}
+ }}
+ />
+ ))}
)
})}
@@ -6202,8 +6224,12 @@ function SourceControlInner(): React.JSX.Element {
}
/>
{!collapsedSections.has('branch') &&
- (sourceControlViewMode === 'tree'
- ? visibleBranchTreeRows.map((node) => {
+ (sourceControlViewMode === 'tree' ? (
+ node.key}
+ renderRow={(node) => {
if (node.type === 'directory') {
return (
)
- })
- : filteredBranchEntries.map((entry) => (
+ }}
+ />
+ ) : (
+ `branch:${entry.path}`}
+ renderRow={(entry) => (
openCommittedDiff(entry, event)}
commentCount={diffCommentCountByPath.get(entry.path) ?? 0}
/>
- )))}
+ )}
+ />
+ ))}
)}
diff --git a/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx
new file mode 100644
index 00000000000..2dc5f0bc474
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx
@@ -0,0 +1,430 @@
+// @vitest-environment happy-dom
+
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import type { GitStatusEntry } from '../../../../shared/types'
+import SourceControl from './SourceControl'
+import {
+ SOURCE_CONTROL_FILE_ROW_HEIGHT_PX,
+ SOURCE_CONTROL_FILE_ROW_OVERSCAN,
+ SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS
+} from './source-control-virtual-file-list'
+
+const mocks = vi.hoisted(() => {
+ const activeRepo = {
+ id: 'repo-1',
+ path: '/repo',
+ displayName: 'Repo',
+ badgeColor: '#000',
+ addedAt: 0
+ }
+ const activeWorktree = {
+ id: 'wt-1',
+ repoId: 'repo-1',
+ path: '/repo/wt',
+ head: 'abcdef123',
+ branch: 'refs/heads/feature/virtual-list',
+ isBare: false,
+ isMainWorktree: false,
+ displayName: 'feature/virtual-list',
+ comment: '',
+ linkedIssue: null,
+ linkedPR: null,
+ linkedLinearIssue: null,
+ linkedGitLabMR: null,
+ linkedGitLabIssue: null,
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 0,
+ lastActivityAt: 0
+ }
+ return {
+ activeRepo,
+ activeWorktree,
+ state: {} as Record
+ }
+})
+
+vi.mock('@/store', () => {
+ const useAppStore = Object.assign(
+ (selector?: (state: Record) => unknown) =>
+ selector ? selector(mocks.state) : mocks.state,
+ {
+ getState: () => mocks.state
+ }
+ )
+ return { useAppStore }
+})
+
+vi.mock('@/store/selectors', () => ({
+ useActiveWorktree: () => mocks.activeWorktree,
+ useRepoById: (repoId: string | null) =>
+ repoId === mocks.activeRepo.id ? mocks.activeRepo : null,
+ useWorktreeMap: () => new Map([[mocks.activeWorktree.id, mocks.activeWorktree]])
+}))
+
+vi.mock('@/components/confirmation-dialog', () => ({
+ useConfirmationDialog: () => vi.fn().mockResolvedValue(true)
+}))
+
+vi.mock('./git-status-refresh', () => ({
+ refreshGitStatusForWorktree: vi.fn().mockResolvedValue(undefined)
+}))
+
+const VIEWPORT_HEIGHT_PX = 600
+// Rows the viewport can show plus overscan on both edges plus the partial
+// rows clipped at each edge of the window.
+const MAX_MOUNTED_ROWS =
+ Math.ceil(VIEWPORT_HEIGHT_PX / SOURCE_CONTROL_FILE_ROW_HEIGHT_PX) +
+ 2 * SOURCE_CONTROL_FILE_ROW_OVERSCAN +
+ 2
+
+function gitEntry(overrides: Partial): GitStatusEntry {
+ return {
+ path: 'src/file.ts',
+ area: 'unstaged',
+ status: 'modified',
+ added: 1,
+ removed: 0,
+ ...overrides
+ }
+}
+
+function manyEntries(count: number): GitStatusEntry[] {
+ return Array.from({ length: count }, (_, index) =>
+ gitEntry({ path: `src/file-${String(index).padStart(3, '0')}.ts` })
+ )
+}
+
+function noopAsync(value: unknown = undefined): () => Promise {
+ return vi.fn().mockResolvedValue(value)
+}
+
+function resetState(overrides: Partial> = {}): void {
+ vi.clearAllMocks()
+ mocks.state = {
+ activeWorktreeId: mocks.activeWorktree.id,
+ activeGroupIdByWorktree: { [mocks.activeWorktree.id]: 'group-1' },
+ groupsByWorktree: { [mocks.activeWorktree.id]: [{ id: 'group-1', activeTabId: null }] },
+ repos: [mocks.activeRepo],
+ worktreesByRepo: { [mocks.activeRepo.id]: [mocks.activeWorktree] },
+ rightSidebarOpen: false,
+ rightSidebarTab: 'source-control',
+ gitStatusByWorktree: { [mocks.activeWorktree.id]: [] },
+ gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] },
+ gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null },
+ gitConflictOperationByWorktree: {},
+ remoteStatusesByWorktree: {},
+ isRemoteOperationActive: false,
+ inFlightRemoteOpKind: null,
+ settings: null,
+ hostedReviewCache: {},
+ prCache: {},
+ commitMessageGenerationRecords: {},
+ pullRequestGenerationRecords: {},
+ openFiles: [],
+ activeFileIdByWorktree: {},
+ activeTabTypeByWorktree: {},
+ getDiffComments: vi.fn(() => []),
+ updateSettings: noopAsync(),
+ openSettingsTarget: vi.fn(),
+ openSettingsPage: vi.fn(),
+ fetchHostedReviewForBranch: noopAsync(),
+ getHostedReviewCreationEligibility: noopAsync(null),
+ createHostedReview: noopAsync({ ok: false, error: 'not available' }),
+ updateWorktreeMeta: noopAsync(),
+ fetchPRForBranch: noopAsync(),
+ enqueueGitHubPRRefresh: vi.fn(),
+ updateRepo: noopAsync(),
+ setGitStatus: vi.fn(),
+ updateWorktreeGitIdentity: vi.fn(),
+ beginGitBranchCompareRequest: vi.fn(() => 'request-key'),
+ setGitBranchCompareResult: vi.fn(),
+ clearGitBranchCompare: vi.fn(),
+ fetchUpstreamStatus: noopAsync(),
+ setUpstreamStatus: vi.fn(),
+ pushBranch: noopAsync(),
+ pullBranch: noopAsync(),
+ fastForwardBranch: noopAsync(),
+ syncBranch: noopAsync(),
+ rebaseFromBase: noopAsync(),
+ fetchBranch: noopAsync(),
+ revealInExplorer: vi.fn(),
+ trackConflictPath: vi.fn(),
+ openDiff: vi.fn(),
+ openFile: vi.fn(),
+ setEditorViewMode: vi.fn(),
+ setMarkdownViewMode: vi.fn(),
+ setPendingEditorReveal: vi.fn(),
+ openConflictFile: vi.fn(),
+ openConflictReview: vi.fn(),
+ openBranchDiff: vi.fn(),
+ createEmptySplitGroup: vi.fn(() => 'group-2'),
+ openAllDiffs: vi.fn(),
+ openBranchAllDiffs: vi.fn(),
+ openCommitAllDiffs: vi.fn(),
+ deleteDiffComment: noopAsync(true),
+ clearDiffComments: noopAsync(true),
+ clearDiffCommentsForFile: noopAsync(true),
+ setScrollToDiffCommentId: vi.fn(),
+ setRightSidebarOpen: vi.fn(),
+ setRightSidebarTab: vi.fn(),
+ allocateCommitMessageGenerationRequestId: vi.fn(() => 'commit-generation-1'),
+ setCommitMessageGenerationRecord: vi.fn(),
+ updateCommitMessageGenerationRecord: vi.fn(),
+ pruneCommitMessageGenerationRecords: vi.fn(),
+ allocatePullRequestGenerationRequestId: vi.fn(() => 'pr-generation-1'),
+ setPullRequestGenerationRecord: vi.fn(),
+ updatePullRequestGenerationRecord: vi.fn(),
+ prunePullRequestGenerationRecords: vi.fn(),
+ ...overrides
+ }
+}
+
+let container: HTMLDivElement
+let root: Root
+
+class NoopResizeObserver {
+ observe(): void {}
+ unobserve(): void {}
+ disconnect(): void {}
+}
+
+beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ resetState()
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+
+ // Why: happy-dom has no layout. Give the virtualizer a 600px scroll viewport
+ // and 24px rows (both rect + row measurement read offsetHeight), and keep
+ // the observer path inert so measurements stay deterministic.
+ vi.stubGlobal('ResizeObserver', NoopResizeObserver)
+ vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(
+ function (this: HTMLElement) {
+ return this.classList.contains('overflow-auto')
+ ? VIEWPORT_HEIGHT_PX
+ : SOURCE_CONTROL_FILE_ROW_HEIGHT_PX
+ }
+ )
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
+ // The panel scroller acts as the fixed viewport; everything inside it
+ // shifts up by scrollTop, which is what the list's scroll-margin math
+ // reads. Leaf rows only ever contribute their 24px height.
+ const isScroller = this.classList.contains('overflow-auto')
+ const scroller = isScroller ? null : this.closest('.overflow-auto')
+ const top = isScroller ? 0 : -(scroller?.scrollTop ?? 0)
+ return {
+ top,
+ bottom: top + SOURCE_CONTROL_FILE_ROW_HEIGHT_PX,
+ height: SOURCE_CONTROL_FILE_ROW_HEIGHT_PX,
+ left: 0,
+ right: 240,
+ width: 240,
+ x: 0,
+ y: top,
+ toJSON: () => ({})
+ } as DOMRect
+ })
+})
+
+afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+})
+
+function renderSourceControl(): void {
+ act(() => {
+ root.render(
+
+
+
+ )
+ })
+}
+
+function scroller(): HTMLDivElement {
+ const element = container.querySelector('.overflow-auto')
+ if (!element) {
+ throw new Error('source control scroller not found')
+ }
+ return element
+}
+
+function scrollTo(offset: number): void {
+ const element = scroller()
+ // Why: happy-dom clamps scrollTop against its zero-height layout; pin the
+ // property so the virtualizer's scroll handler reads the intended offset.
+ Object.defineProperty(element, 'scrollTop', {
+ configurable: true,
+ writable: true,
+ value: offset
+ })
+ act(() => {
+ element.dispatchEvent(new Event('scroll'))
+ })
+}
+
+function mountedRows(): HTMLDivElement[] {
+ return Array.from(
+ container.querySelectorAll('[data-testid="source-control-entry"]')
+ )
+}
+
+function row(path: string): HTMLDivElement | null {
+ return container.querySelector(
+ `[data-source-control-path="${path}"][data-source-control-area="unstaged"]`
+ )
+}
+
+function virtualList(): HTMLDivElement | null {
+ return container.querySelector('[data-testid="source-control-virtual-list"]')
+}
+
+describe('SourceControl virtualized changed-files list', () => {
+ it('bounds mounted rows by viewport + overscan with 500 entries', () => {
+ resetState({
+ gitStatusByWorktree: { [mocks.activeWorktree.id]: manyEntries(500) }
+ })
+ renderSourceControl()
+
+ expect(virtualList()).toBeTruthy()
+ const mounted = mountedRows().length
+ expect(mounted).toBeGreaterThan(0)
+ expect(mounted).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
+ })
+
+ it('windows rows while scrolling instead of mounting the tail', () => {
+ resetState({
+ gitStatusByWorktree: { [mocks.activeWorktree.id]: manyEntries(500) }
+ })
+ renderSourceControl()
+
+ expect(row('src/file-000.ts')).toBeTruthy()
+ expect(row('src/file-250.ts')).toBeNull()
+
+ scrollTo(250 * SOURCE_CONTROL_FILE_ROW_HEIGHT_PX)
+
+ expect(row('src/file-250.ts')).toBeTruthy()
+ expect(row('src/file-000.ts')).toBeNull()
+ expect(mountedRows().length).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
+ })
+
+ it('keeps row selection across the virtualized boundary', () => {
+ resetState({
+ gitStatusByWorktree: { [mocks.activeWorktree.id]: manyEntries(500) }
+ })
+ renderSourceControl()
+
+ const target = row('src/file-000.ts')
+ expect(target).toBeTruthy()
+ act(() => {
+ target?.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true }))
+ })
+ expect(row('src/file-000.ts')?.className).toContain('bg-accent/60')
+ expect(container.textContent).toContain('1 selected')
+
+ // The selected row scrolls out of the mounted window but stays selected.
+ scrollTo(240 * SOURCE_CONTROL_FILE_ROW_HEIGHT_PX)
+ expect(row('src/file-000.ts')).toBeNull()
+ expect(container.textContent).toContain('1 selected')
+
+ scrollTo(0)
+ expect(row('src/file-000.ts')?.className).toContain('bg-accent/60')
+ })
+
+ it('collapses and re-expands a virtualized section', () => {
+ resetState({
+ gitStatusByWorktree: { [mocks.activeWorktree.id]: manyEntries(500) }
+ })
+ renderSourceControl()
+ expect(mountedRows().length).toBeGreaterThan(0)
+
+ const changesHeader = Array.from(container.querySelectorAll('button')).find((button) =>
+ button.textContent?.includes('Changes')
+ )
+ expect(changesHeader).toBeTruthy()
+
+ act(() => {
+ changesHeader?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+ expect(mountedRows().length).toBe(0)
+ expect(virtualList()).toBeNull()
+
+ act(() => {
+ changesHeader?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+ expect(mountedRows().length).toBeGreaterThan(0)
+ expect(mountedRows().length).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
+ })
+
+ it('keeps the scroll offset and window when a refresh delivers identical entries', () => {
+ resetState({
+ gitStatusByWorktree: { [mocks.activeWorktree.id]: manyEntries(500) }
+ })
+ renderSourceControl()
+
+ scrollTo(100 * SOURCE_CONTROL_FILE_ROW_HEIGHT_PX)
+ expect(row('src/file-100.ts')).toBeTruthy()
+ const heightBefore = virtualList()?.style.height
+
+ // A status refresh rebuilds every entry object with identical content.
+ mocks.state = {
+ ...mocks.state,
+ gitStatusByWorktree: {
+ [mocks.activeWorktree.id]: manyEntries(500).map((entry) => ({ ...entry }))
+ }
+ }
+ renderSourceControl()
+
+ expect(scroller().scrollTop).toBe(100 * SOURCE_CONTROL_FILE_ROW_HEIGHT_PX)
+ expect(row('src/file-100.ts')).toBeTruthy()
+ expect(virtualList()?.style.height).toBe(heightBefore)
+ })
+
+ it('renders small changesets in full without the virtualization container', () => {
+ const paths = ['src/a.ts', 'src/b.ts', 'src/c.ts']
+ resetState({
+ gitStatusByWorktree: {
+ [mocks.activeWorktree.id]: paths.map((path) => gitEntry({ path }))
+ }
+ })
+ renderSourceControl()
+
+ expect(paths.length).toBeLessThan(SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS)
+ expect(virtualList()).toBeNull()
+ expect(mountedRows().length).toBe(paths.length)
+ for (const path of paths) {
+ expect(row(path)).toBeTruthy()
+ }
+ })
+
+ it('bounds mounted tree-view rows for large changesets', () => {
+ resetState({
+ settings: { sourceControlViewMode: 'tree' },
+ gitStatusByWorktree: {
+ [mocks.activeWorktree.id]: Array.from({ length: 500 }, (_, index) =>
+ gitEntry({
+ path: `src/dir-${String(Math.floor(index / 20)).padStart(2, '0')}/file-${String(
+ index
+ ).padStart(3, '0')}.ts`
+ })
+ )
+ }
+ })
+ renderSourceControl()
+
+ expect(virtualList()).toBeTruthy()
+ const mounted = container.querySelectorAll(
+ '[data-testid="source-control-virtual-list"] [data-index]'
+ ).length
+ expect(mounted).toBeGreaterThan(0)
+ expect(mounted).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
+ })
+})
diff --git a/src/renderer/src/components/right-sidebar/source-control-virtual-file-list.tsx b/src/renderer/src/components/right-sidebar/source-control-virtual-file-list.tsx
new file mode 100644
index 00000000000..8c71cbe7279
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/source-control-virtual-file-list.tsx
@@ -0,0 +1,106 @@
+import { useLayoutEffect, useRef, useState } from 'react'
+import { useVirtualizer } from '@tanstack/react-virtual'
+
+// Why: below this count plain rows keep the DOM identical to the
+// pre-virtualization markup (natural flow, no absolute positioning), so small
+// changesets keep exact scrollbar and flicker-free behavior. `STA-351` /
+// `STA-1280` jank only appears with hundreds of rows.
+export const SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS = 50
+// Why: rows are one py-1 text-xs line, except conflict/submodule rows which
+// add a second label line — so estimate the common height and let
+// measureElement correct the tall variants. Identical entries measure
+// identically, so a git-status refresh cannot move the scroll position.
+export const SOURCE_CONTROL_FILE_ROW_HEIGHT_PX = 24
+export const SOURCE_CONTROL_FILE_ROW_OVERSCAN = 10
+
+/**
+ * Windows one source-control section's rows inside the panel's shared
+ * scroller. Sections below SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS render plainly;
+ * larger ones mount only viewport + overscan rows.
+ */
+export function SourceControlVirtualFileList({
+ rows,
+ getRowKey,
+ renderRow,
+ scrollElement
+}: {
+ rows: readonly TRow[]
+ getRowKey: (row: TRow) => string
+ renderRow: (row: TRow) => React.ReactNode
+ // Why: a state-held element, not a ref — ancestor host refs are not attached
+ // yet when this component's mount effects run, so a ref would leave the
+ // virtualizer unobserved until some unrelated re-render.
+ scrollElement: HTMLDivElement | null
+}): React.JSX.Element {
+ const containerRef = useRef(null)
+ const [scrollMargin, setScrollMargin] = useState(0)
+ const virtualize = rows.length >= SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS
+
+ // Why: the section shares the panel scroller with the commit area and
+ // sibling sections, so the virtualizer needs this list's offset inside that
+ // scroller. Everything above changes height only through React renders
+ // (commit drafts, banners, section collapse), so a per-render layout read
+ // keeps the margin current without observers. The guarded setState makes the
+ // measure/render loop converge in one extra pass.
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- no dependency list on purpose: any sibling render can move this list inside the shared scroller.
+ useLayoutEffect(() => {
+ const container = containerRef.current
+ if (!container || !scrollElement) {
+ return
+ }
+ const nextMargin = Math.round(
+ container.getBoundingClientRect().top -
+ scrollElement.getBoundingClientRect().top +
+ scrollElement.scrollTop
+ )
+ setScrollMargin((current) => (current === nextMargin ? current : nextMargin))
+ })
+
+ const virtualizer = useVirtualizer({
+ count: rows.length,
+ enabled: virtualize && scrollElement !== null,
+ getScrollElement: () => scrollElement,
+ estimateSize: () => SOURCE_CONTROL_FILE_ROW_HEIGHT_PX,
+ overscan: SOURCE_CONTROL_FILE_ROW_OVERSCAN,
+ scrollMargin,
+ // Why: stable row keys let the virtualizer carry item identity across
+ // status refreshes instead of remounting the window each poll.
+ getItemKey: (index) => {
+ const row = rows[index]
+ return row === undefined ? index : getRowKey(row)
+ }
+ })
+
+ if (!virtualize) {
+ return <>{rows.map((row) => renderRow(row))}>
+ }
+
+ return (
+
+ {virtualizer.getVirtualItems().map((item) => {
+ const row = rows[item.index]
+ if (row === undefined) {
+ return null
+ }
+ return (
+
+ {renderRow(row)}
+
+ )
+ })}
+
+ )
+}
diff --git a/src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts b/src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts
new file mode 100644
index 00000000000..09de670c689
--- /dev/null
+++ b/src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts
@@ -0,0 +1,365 @@
+import { describe, expect, it, vi } from 'vitest'
+import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
+import { useAppStore, type AppState } from '@/store'
+import {
+ runKillAllTerminalSurfaces,
+ snapshotKillAllTerminalSurfaceIds,
+ type KillAllTerminalSurfaceState
+} from './kill-all-terminal-surfaces'
+
+type TerminalRow = AppState['tabsByWorktree'][string][number]
+type UnifiedRow = AppState['unifiedTabsByWorktree'][string][number]
+
+function terminal(id: string, worktreeId: string): TerminalRow {
+ return { id, worktreeId } as TerminalRow
+}
+
+function unified(
+ id: string,
+ entityId: string,
+ worktreeId: string,
+ contentType: UnifiedRow['contentType'] = 'terminal'
+): UnifiedRow {
+ return { id, entityId, worktreeId, contentType } as UnifiedRow
+}
+
+function state(overrides: Partial = {}): KillAllTerminalSurfaceState {
+ return {
+ activeWorktreeId: null,
+ tabsByWorktree: {},
+ unifiedTabsByWorktree: {},
+ ptyIdsByTabId: {},
+ ...overrides
+ }
+}
+
+function removeSurface(current: KillAllTerminalSurfaceState, targetId: string): void {
+ current.tabsByWorktree = Object.fromEntries(
+ Object.entries(current.tabsByWorktree).map(([worktreeId, tabs]) => [
+ worktreeId,
+ tabs.filter((tab) => tab.id !== targetId)
+ ])
+ )
+ current.unifiedTabsByWorktree = Object.fromEntries(
+ Object.entries(current.unifiedTabsByWorktree).map(([worktreeId, tabs]) => [
+ worktreeId,
+ tabs.filter(
+ (tab) =>
+ tab.contentType !== 'terminal' || (tab.entityId !== targetId && tab.id !== targetId)
+ )
+ ])
+ )
+}
+
+function deferred(): {
+ promise: Promise
+ resolve: (value: T) => void
+ reject: (reason: unknown) => void
+} {
+ let resolve!: (value: T) => void
+ let reject!: (reason: unknown) => void
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise
+ reject = rejectPromise
+ })
+ return { promise, resolve, reject }
+}
+
+describe('snapshotKillAllTerminalSurfaceIds', () => {
+ it('deduplicates terminal entities across legacy, unified, split, and floating state', () => {
+ const snapshot = snapshotKillAllTerminalSurfaceIds(
+ state({
+ tabsByWorktree: {
+ 'wt-a': [terminal('tab-a', 'wt-a'), terminal('split-tab', 'wt-a')],
+ 'wt-b': [terminal('tab-b', 'wt-b')],
+ [FLOATING_TERMINAL_WORKTREE_ID]: [terminal('floating-tab', FLOATING_TERMINAL_WORKTREE_ID)]
+ },
+ unifiedTabsByWorktree: {
+ 'wt-a': [
+ unified('visible-a', 'tab-a', 'wt-a'),
+ unified('unified-only', 'unified-only', 'wt-a'),
+ unified('editor-a', 'file-a', 'wt-a', 'editor')
+ ],
+ [FLOATING_TERMINAL_WORKTREE_ID]: [
+ unified('floating-visible', 'floating-tab', FLOATING_TERMINAL_WORKTREE_ID)
+ ]
+ },
+ ptyIdsByTabId: {
+ 'split-tab': ['pty-left', 'pty-right']
+ }
+ })
+ )
+
+ expect(snapshot).toEqual(['tab-a', 'split-tab', 'tab-b', 'floating-tab', 'unified-only'])
+ })
+})
+
+describe('runKillAllTerminalSurfaces', () => {
+ it('resolves current bindings and ownership after management settles, then closes active last', async () => {
+ const management = deferred<{ killedCount: number; remainingCount: number }>()
+ const lastExactKill = deferred()
+ let current = state({
+ activeWorktreeId: 'wt-old-active',
+ tabsByWorktree: {
+ 'wt-old-active': [terminal('moved-target', 'wt-old-active')],
+ 'wt-background': [
+ terminal('background-target', 'wt-background'),
+ terminal('missing-target', 'wt-background')
+ ]
+ },
+ unifiedTabsByWorktree: {
+ 'wt-background': [unified('unified-target', 'unified-only-target', 'wt-background')]
+ },
+ ptyIdsByTabId: {
+ 'moved-target': ['stale-pty'],
+ 'background-target': ['stale-background-pty'],
+ 'missing-target': ['stale-missing-pty'],
+ 'unified-only-target': ['stale-unified-pty']
+ }
+ })
+ const targetIds = snapshotKillAllTerminalSurfaceIds(current)
+ const calls: string[] = []
+ const killDaemonSessions = vi.fn(() => management.promise)
+ const closeSurface = vi.fn((targetId: string) => {
+ calls.push(`close:${targetId}`)
+ removeSurface(current, targetId)
+ })
+ const killPty = vi.fn((ptyId: string) => {
+ calls.push(`kill:${ptyId}`)
+ if (ptyId === 'pty-unified') {
+ return Promise.reject(new Error('provider unavailable'))
+ }
+ if (ptyId === 'ssh:host@@pty-last') {
+ return lastExactKill.promise
+ }
+ return Promise.resolve()
+ })
+ const reportSummary = vi.fn()
+
+ const completion = runKillAllTerminalSurfaces(targetIds, {
+ getState: () => current,
+ killDaemonSessions,
+ closeSurface,
+ killPty,
+ reportSummary
+ })
+
+ expect(killDaemonSessions).toHaveBeenCalledTimes(1)
+ expect(closeSurface).not.toHaveBeenCalled()
+
+ current = state({
+ activeWorktreeId: 'wt-new-active',
+ tabsByWorktree: {
+ 'wt-background': [terminal('background-target', 'wt-background')],
+ 'wt-new-active': [
+ {
+ ...terminal('moved-target', 'wt-new-active'),
+ ptyId: 'tab-restore-hint'
+ },
+ terminal('later-tab', 'wt-new-active')
+ ]
+ },
+ unifiedTabsByWorktree: {
+ [FLOATING_TERMINAL_WORKTREE_ID]: [
+ unified('unified-target-moved', 'unified-only-target', FLOATING_TERMINAL_WORKTREE_ID)
+ ],
+ 'wt-new-active': [unified('later-visible', 'later-tab', 'wt-new-active')]
+ },
+ ptyIdsByTabId: {
+ 'background-target': ['pty-shared', 'remote:runtime-only'],
+ 'unified-only-target': ['pty-unified', 'pty-shared'],
+ 'moved-target': ['ssh:host@@pty-last', 'pty-shared'],
+ 'later-tab': ['pty-later']
+ }
+ })
+ ;(
+ current as KillAllTerminalSurfaceState & {
+ terminalLayoutsByTabId: Record }>
+ }
+ ).terminalLayoutsByTabId = {
+ 'moved-target': { ptyIdsByLeafId: { leaf: 'layout-restore-hint' } }
+ }
+ management.resolve({ killedCount: 2, remainingCount: 1 })
+ await vi.waitFor(() => expect(killPty).toHaveBeenCalledTimes(3))
+
+ expect(closeSurface.mock.calls).toEqual([
+ ['background-target', { force: true }],
+ ['unified-only-target', { force: true }],
+ ['moved-target', { force: true }]
+ ])
+ expect(calls.slice(0, 3)).toEqual([
+ 'close:background-target',
+ 'close:unified-only-target',
+ 'close:moved-target'
+ ])
+ expect(killPty).toHaveBeenCalledWith('pty-shared')
+ expect(killPty).toHaveBeenCalledWith('pty-unified')
+ expect(killPty).toHaveBeenCalledWith('ssh:host@@pty-last')
+ expect(killPty).not.toHaveBeenCalledWith('remote:runtime-only')
+ expect(killPty).not.toHaveBeenCalledWith('pty-later')
+ expect(killPty).not.toHaveBeenCalledWith('stale-pty')
+ expect(killPty).not.toHaveBeenCalledWith('tab-restore-hint')
+ expect(killPty).not.toHaveBeenCalledWith('layout-restore-hint')
+ expect(snapshotKillAllTerminalSurfaceIds(current)).toEqual(['later-tab'])
+
+ let settled = false
+ void completion.then(() => {
+ settled = true
+ })
+ await Promise.resolve()
+ expect(settled).toBe(false)
+ lastExactKill.resolve()
+
+ await expect(completion).resolves.toMatchObject({
+ targetCount: 4,
+ closeAttemptCount: 3,
+ absentTargetCount: 4,
+ failedCloseAttemptCount: 0,
+ exactKillAcceptedCount: 2,
+ exactKillRejectedCount: 1,
+ daemon: { status: 'fulfilled', killedCount: 2, remainingCount: 1 }
+ })
+ expect(reportSummary).toHaveBeenCalledTimes(1)
+ })
+
+ it('still closes and issues exact kills when daemon management rejects', async () => {
+ let current = state({
+ tabsByWorktree: { wt: [terminal('target', 'wt'), terminal('later', 'wt')] },
+ ptyIdsByTabId: { target: ['local-pty'], later: ['later-pty'] }
+ })
+ const closeSurface = vi.fn((targetId: string) => removeSurface(current, targetId))
+ const killPty = vi.fn().mockResolvedValue(undefined)
+
+ const summary = await runKillAllTerminalSurfaces(['target'], {
+ getState: () => current,
+ killDaemonSessions: vi.fn().mockRejectedValue(new Error('management unavailable')),
+ closeSurface,
+ killPty,
+ reportSummary: vi.fn()
+ })
+
+ expect(summary.daemon).toEqual({ status: 'rejected' })
+ expect(summary.absentTargetCount).toBe(1)
+ expect(closeSurface).toHaveBeenCalledWith('target', { force: true })
+ expect(killPty).toHaveBeenCalledWith('local-pty')
+ expect(snapshotKillAllTerminalSurfaceIds(current)).toEqual(['later'])
+ })
+
+ it('reports the informational zero state without provider fanout', async () => {
+ const killDaemonSessions = vi.fn().mockResolvedValue({ killedCount: 0, remainingCount: 0 })
+ const closeSurface = vi.fn()
+ const killPty = vi.fn()
+
+ const summary = await runKillAllTerminalSurfaces([], {
+ getState: () => state(),
+ killDaemonSessions,
+ closeSurface,
+ killPty,
+ reportSummary: vi.fn()
+ })
+
+ expect(summary).toMatchObject({
+ targetCount: 0,
+ closeAttemptCount: 0,
+ absentTargetCount: 0,
+ exactKillAcceptedCount: 0,
+ exactKillRejectedCount: 0,
+ daemon: { status: 'fulfilled', killedCount: 0, remainingCount: 0 }
+ })
+ expect(killDaemonSessions).toHaveBeenCalledTimes(1)
+ expect(closeSurface).not.toHaveBeenCalled()
+ expect(killPty).not.toHaveBeenCalled()
+ })
+
+ it('uses one daemon management call and never inventories sessions afterward', async () => {
+ const killAll = vi.fn().mockResolvedValue({ killedCount: 0, remainingCount: 0 })
+ const listSessions = vi.fn()
+ vi.stubGlobal('window', {
+ api: {
+ pty: {
+ kill: vi.fn(),
+ management: { killAll, listSessions }
+ }
+ }
+ })
+ try {
+ await runKillAllTerminalSurfaces([], {
+ getState: () => state(),
+ reportSummary: vi.fn()
+ })
+
+ expect(killAll).toHaveBeenCalledTimes(1)
+ expect(listSessions).not.toHaveBeenCalled()
+ } finally {
+ vi.unstubAllGlobals()
+ }
+ })
+
+ it('settles each close attempt and verifies final absence in both tab stores', async () => {
+ const current = state({
+ tabsByWorktree: {
+ wt: [terminal('throws-after-close', 'wt'), terminal('remains', 'wt')]
+ },
+ unifiedTabsByWorktree: {
+ wt: [unified('remains-visible', 'remains', 'wt')]
+ }
+ })
+ const closeSurface = vi.fn((targetId: string) => {
+ if (targetId === 'throws-after-close') {
+ removeSurface(current, targetId)
+ throw new Error('post-close store failure')
+ }
+ })
+
+ const summary = await runKillAllTerminalSurfaces(['throws-after-close', 'remains'], {
+ getState: () => current,
+ killDaemonSessions: vi.fn().mockResolvedValue({ killedCount: 0, remainingCount: 0 }),
+ closeSurface,
+ killPty: vi.fn(),
+ reportSummary: vi.fn()
+ })
+
+ expect(closeSurface).toHaveBeenCalledTimes(2)
+ expect(summary).toMatchObject({
+ absentTargetCount: 1,
+ failedCloseAttemptCount: 2
+ })
+ })
+
+ it('records bounded count and latency evidence for 100 terminal tabs', async () => {
+ const tabs = Array.from({ length: 100 }, (_, index) => terminal(`tab-${index}`, 'wt'))
+ const ptyIdsByTabId = Object.fromEntries(tabs.map((tab, index) => [tab.id, [`pty-${index}`]]))
+ const previousState = useAppStore.getState()
+ useAppStore.setState({
+ activeWorktreeId: null,
+ tabsByWorktree: { wt: tabs },
+ unifiedTabsByWorktree: {},
+ ptyIdsByTabId
+ })
+ let zustandWrites = 0
+ const unsubscribe = useAppStore.subscribe(() => {
+ zustandWrites += 1
+ })
+ const killPty = vi.fn().mockResolvedValue(undefined)
+ try {
+ const summary = await runKillAllTerminalSurfaces(snapshotKillAllTerminalSurfaceIds(), {
+ killDaemonSessions: vi.fn().mockResolvedValue({ killedCount: 0, remainingCount: 0 }),
+ killPty,
+ reportSummary: vi.fn()
+ })
+
+ expect(summary.closeAttemptCount).toBe(100)
+ expect(summary.absentTargetCount).toBe(100)
+ expect(summary.exactKillAcceptedCount).toBe(100)
+ expect(summary.closeDurationMs).toBeGreaterThanOrEqual(0)
+ expect(summary.maxCloseBatchDurationMs).toBeLessThanOrEqual(50)
+ expect(summary.closeYieldCount).toBe(49)
+ expect(summary.closePhaseExceededLongTaskBudget).toBe(false)
+ expect(zustandWrites).toBeGreaterThanOrEqual(100)
+ expect(killPty).toHaveBeenCalledTimes(100)
+ } finally {
+ unsubscribe()
+ useAppStore.setState(previousState, true)
+ }
+ })
+})
diff --git a/src/renderer/src/components/shared/kill-all-terminal-surfaces.ts b/src/renderer/src/components/shared/kill-all-terminal-surfaces.ts
new file mode 100644
index 00000000000..4509b2017a1
--- /dev/null
+++ b/src/renderer/src/components/shared/kill-all-terminal-surfaces.ts
@@ -0,0 +1,215 @@
+import { useAppStore, type AppState } from '@/store'
+import { closeTerminalTab } from '../terminal/terminal-tab-actions'
+
+const CLOSE_BATCH_SIZE = 2
+
+type DaemonKillAllResult = {
+ killedCount: number
+ remainingCount: number
+}
+
+export type KillAllTerminalSurfaceState = Pick<
+ AppState,
+ 'activeWorktreeId' | 'ptyIdsByTabId' | 'tabsByWorktree' | 'unifiedTabsByWorktree'
+>
+
+export type KillAllTerminalSurfacesSummary = {
+ targetCount: number
+ closeAttemptCount: number
+ absentTargetCount: number
+ failedCloseAttemptCount: number
+ exactKillAcceptedCount: number
+ exactKillRejectedCount: number
+ closeDurationMs: number
+ maxCloseBatchDurationMs: number
+ closeYieldCount: number
+ closePhaseExceededLongTaskBudget: boolean
+ daemon:
+ | ({ status: 'fulfilled' } & DaemonKillAllResult)
+ | {
+ status: 'rejected'
+ }
+}
+
+type KillAllTerminalSurfaceDependencies = {
+ getState: () => KillAllTerminalSurfaceState
+ killDaemonSessions: () => Promise
+ closeSurface: (tabId: string, options: { force: true }) => void
+ killPty: (ptyId: string) => Promise
+ now: () => number
+ yieldToRenderer: () => Promise
+ reportSummary: (summary: KillAllTerminalSurfacesSummary) => void
+}
+
+export function snapshotKillAllTerminalSurfaceIds(
+ state: KillAllTerminalSurfaceState = useAppStore.getState()
+): string[] {
+ const targetIds = new Set()
+ for (const tabs of Object.values(state.tabsByWorktree)) {
+ for (const tab of tabs) {
+ targetIds.add(tab.id)
+ }
+ }
+ for (const tabs of Object.values(state.unifiedTabsByWorktree)) {
+ for (const tab of tabs) {
+ if (tab.contentType === 'terminal') {
+ targetIds.add(tab.entityId)
+ }
+ }
+ }
+ return [...targetIds]
+}
+
+function getTargetOwners(
+ state: KillAllTerminalSurfaceState,
+ targetIds: ReadonlySet
+): Map {
+ const ownerByTargetId = new Map()
+ for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) {
+ for (const tab of tabs) {
+ if (targetIds.has(tab.id) && !ownerByTargetId.has(tab.id)) {
+ ownerByTargetId.set(tab.id, worktreeId)
+ }
+ }
+ }
+ for (const [worktreeId, tabs] of Object.entries(state.unifiedTabsByWorktree)) {
+ for (const tab of tabs) {
+ if (
+ tab.contentType === 'terminal' &&
+ targetIds.has(tab.entityId) &&
+ !ownerByTargetId.has(tab.entityId)
+ ) {
+ ownerByTargetId.set(tab.entityId, worktreeId)
+ }
+ }
+ }
+ return ownerByTargetId
+}
+
+function isTargetPresent(state: KillAllTerminalSurfaceState, targetId: string): boolean {
+ return snapshotKillAllTerminalSurfaceIds(state).includes(targetId)
+}
+
+function createDefaultDependencies(): KillAllTerminalSurfaceDependencies {
+ return {
+ getState: useAppStore.getState,
+ killDaemonSessions: () => window.api.pty.management.killAll(),
+ closeSurface: closeTerminalTab,
+ killPty: (ptyId) => window.api.pty.kill(ptyId),
+ now: () => globalThis.performance?.now() ?? Date.now(),
+ yieldToRenderer: () =>
+ new Promise((resolve) => {
+ const channel = new MessageChannel()
+ channel.port1.onmessage = () => {
+ channel.port1.close()
+ channel.port2.close()
+ resolve()
+ }
+ channel.port2.postMessage(undefined)
+ }),
+ reportSummary: (summary) => console.info('[kill-all-terminal-surfaces]', summary)
+ }
+}
+
+export async function runKillAllTerminalSurfaces(
+ snapshotTargetIds: readonly string[],
+ dependencies: Partial = {}
+): Promise {
+ const deps = { ...createDefaultDependencies(), ...dependencies }
+ const targetIds = [...new Set(snapshotTargetIds)]
+ const targetIdSet = new Set(targetIds)
+
+ let daemon: KillAllTerminalSurfacesSummary['daemon']
+ try {
+ daemon = { status: 'fulfilled', ...(await deps.killDaemonSessions()) }
+ } catch {
+ daemon = { status: 'rejected' }
+ }
+
+ const cleanupState = deps.getState()
+ const ownerByTargetId = getTargetOwners(cleanupState, targetIdSet)
+ const presentTargetIds = targetIds.filter((targetId) => ownerByTargetId.has(targetId))
+ const activeWorktreeId = cleanupState.activeWorktreeId
+ // Why: keeping the active worktree until its targets close lets the existing
+ // tab action choose editor/browser/deactivation without spawning a replacement.
+ const closeOrder = [
+ ...presentTargetIds.filter((targetId) => ownerByTargetId.get(targetId) !== activeWorktreeId),
+ ...presentTargetIds.filter((targetId) => ownerByTargetId.get(targetId) === activeWorktreeId)
+ ]
+
+ const exactPtyIds = new Set()
+ for (const targetId of presentTargetIds) {
+ for (const ptyId of cleanupState.ptyIdsByTabId[targetId] ?? []) {
+ if (ptyId.length > 0 && !ptyId.startsWith('remote:')) {
+ exactPtyIds.add(ptyId)
+ }
+ }
+ }
+
+ let failedCloseAttemptCount = 0
+ const closeStartedAt = deps.now()
+ let closeBatchStartedAt = closeStartedAt
+ let maxCloseBatchDurationMs = 0
+ let closeYieldCount = 0
+ for (let index = 0; index < closeOrder.length; index += 1) {
+ const targetId = closeOrder[index]
+ let failed = false
+ try {
+ deps.closeSurface(targetId, { force: true })
+ } catch {
+ failed = true
+ }
+ try {
+ failed ||= isTargetPresent(deps.getState(), targetId)
+ } catch {
+ failed = true
+ }
+ if (failed) {
+ failedCloseAttemptCount += 1
+ }
+ const isBatchEnd = (index + 1) % CLOSE_BATCH_SIZE === 0 || index + 1 === closeOrder.length
+ if (isBatchEnd) {
+ maxCloseBatchDurationMs = Math.max(maxCloseBatchDurationMs, deps.now() - closeBatchStartedAt)
+ }
+ if (isBatchEnd && index + 1 < closeOrder.length) {
+ // Why: closeTab cascades clone several store maps, so large confirmed
+ // snapshots yield between bounded batches instead of monopolizing a frame.
+ try {
+ await deps.yieldToRenderer()
+ } catch {
+ // Yield failure is not cleanup failure; keep closing the confirmed set.
+ }
+ closeYieldCount += 1
+ closeBatchStartedAt = deps.now()
+ }
+ }
+ const closeDurationMs = Math.max(0, deps.now() - closeStartedAt)
+
+ const exactKillResults = await Promise.allSettled(
+ [...exactPtyIds].map((ptyId) => Promise.resolve().then(() => deps.killPty(ptyId)))
+ )
+ const exactKillAcceptedCount = exactKillResults.filter(
+ (result) => result.status === 'fulfilled'
+ ).length
+ const finalTargetIds = new Set(snapshotKillAllTerminalSurfaceIds(deps.getState()))
+ const absentTargetCount = targetIds.filter((targetId) => !finalTargetIds.has(targetId)).length
+ const summary: KillAllTerminalSurfacesSummary = {
+ targetCount: targetIds.length,
+ closeAttemptCount: closeOrder.length,
+ absentTargetCount,
+ failedCloseAttemptCount,
+ exactKillAcceptedCount,
+ exactKillRejectedCount: exactKillResults.length - exactKillAcceptedCount,
+ closeDurationMs: Math.round(closeDurationMs * 100) / 100,
+ maxCloseBatchDurationMs: Math.round(maxCloseBatchDurationMs * 100) / 100,
+ closeYieldCount,
+ closePhaseExceededLongTaskBudget: maxCloseBatchDurationMs > 50,
+ daemon
+ }
+ try {
+ deps.reportSummary(summary)
+ } catch {
+ // Diagnostics must not change the already-settled destructive action.
+ }
+ return summary
+}
diff --git a/src/renderer/src/components/shared/useDaemonActions.test.tsx b/src/renderer/src/components/shared/useDaemonActions.test.tsx
new file mode 100644
index 00000000000..6c9e00b8f73
--- /dev/null
+++ b/src/renderer/src/components/shared/useDaemonActions.test.tsx
@@ -0,0 +1,152 @@
+// @vitest-environment happy-dom
+import { act, renderHook } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { runCleanupMock, snapshotMock, toastErrorMock, toastInfoMock, toastSuccessMock } =
+ vi.hoisted(() => ({
+ runCleanupMock: vi.fn(),
+ snapshotMock: vi.fn(),
+ toastErrorMock: vi.fn(),
+ toastInfoMock: vi.fn(),
+ toastSuccessMock: vi.fn()
+ }))
+
+vi.mock('./kill-all-terminal-surfaces', () => ({
+ runKillAllTerminalSurfaces: runCleanupMock,
+ snapshotKillAllTerminalSurfaceIds: snapshotMock
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ error: toastErrorMock,
+ info: toastInfoMock,
+ success: toastSuccessMock,
+ warning: vi.fn()
+ }
+}))
+
+vi.mock('@/i18n/i18n', () => ({
+ translate: (_key: string, fallback: string) => fallback
+}))
+
+import type { KillAllTerminalSurfacesSummary } from './kill-all-terminal-surfaces'
+import { useDaemonActions } from './useDaemonActions'
+
+function rejectedSummary(): KillAllTerminalSurfacesSummary {
+ return {
+ targetCount: 1,
+ closeAttemptCount: 1,
+ absentTargetCount: 1,
+ failedCloseAttemptCount: 0,
+ exactKillAcceptedCount: 1,
+ exactKillRejectedCount: 0,
+ closeDurationMs: 1,
+ maxCloseBatchDurationMs: 1,
+ closeYieldCount: 0,
+ closePhaseExceededLongTaskBudget: false,
+ daemon: { status: 'rejected' }
+ }
+}
+
+function successfulSurfaceSummary(): KillAllTerminalSurfacesSummary {
+ return {
+ targetCount: 1,
+ closeAttemptCount: 1,
+ absentTargetCount: 1,
+ failedCloseAttemptCount: 0,
+ exactKillAcceptedCount: 0,
+ exactKillRejectedCount: 0,
+ closeDurationMs: 1,
+ maxCloseBatchDurationMs: 1,
+ closeYieldCount: 0,
+ closePhaseExceededLongTaskBudget: false,
+ daemon: { status: 'fulfilled', killedCount: 0, remainingCount: 0 }
+ }
+}
+
+function deferred(): { promise: Promise; resolve: (value: T) => void } {
+ let resolve!: (value: T) => void
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise
+ })
+ return { promise, resolve }
+}
+
+describe('useDaemonActions kill-all cleanup', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ snapshotMock.mockReturnValue(['confirmed-tab'])
+ })
+
+ it('snapshots before start and does not revoke cleanup when the caller unmounts', async () => {
+ const cleanup = deferred()
+ const sequence: string[] = []
+ const onKillAllStart = vi.fn(() => sequence.push('start'))
+ const onKillAllError = vi.fn()
+ const onKillAllSettled = vi.fn()
+ snapshotMock.mockImplementation(() => {
+ sequence.push('snapshot')
+ return ['confirmed-tab']
+ })
+ runCleanupMock.mockImplementation(() => {
+ sequence.push('cleanup')
+ return cleanup.promise
+ })
+ const { result, unmount } = renderHook(() =>
+ useDaemonActions({ onKillAllStart, onKillAllError, onKillAllSettled })
+ )
+
+ let completion!: Promise
+ act(() => {
+ completion = result.current.runKillAll()
+ })
+
+ expect(sequence).toEqual(['snapshot', 'start', 'cleanup'])
+ expect(runCleanupMock).toHaveBeenCalledWith(['confirmed-tab'])
+ unmount()
+ cleanup.resolve(rejectedSummary())
+ await completion
+
+ expect(toastErrorMock).toHaveBeenCalledTimes(1)
+ expect(onKillAllError).not.toHaveBeenCalled()
+ expect(onKillAllSettled).not.toHaveBeenCalled()
+ })
+
+ it('runs mounted error and settled callbacks only after cleanup settlement', async () => {
+ const cleanup = deferred()
+ const onKillAllError = vi.fn()
+ const onKillAllSettled = vi.fn()
+ runCleanupMock.mockReturnValue(cleanup.promise)
+ const { result } = renderHook(() => useDaemonActions({ onKillAllError, onKillAllSettled }))
+
+ let completion!: Promise
+ act(() => {
+ completion = result.current.runKillAll()
+ })
+ expect(onKillAllError).not.toHaveBeenCalled()
+ expect(onKillAllSettled).not.toHaveBeenCalled()
+
+ await act(async () => {
+ cleanup.resolve(rejectedSummary())
+ await completion
+ })
+
+ expect(onKillAllError).toHaveBeenCalledTimes(1)
+ expect(onKillAllSettled).toHaveBeenCalledTimes(1)
+ })
+
+ it('reports closed terminal tabs as success when daemon management found no sessions', async () => {
+ runCleanupMock.mockResolvedValue(successfulSurfaceSummary())
+ const { result } = renderHook(() => useDaemonActions())
+
+ await act(async () => {
+ await result.current.runKillAll()
+ })
+
+ expect(toastSuccessMock).toHaveBeenCalledWith(
+ 'Terminal tabs closed and shutdown requested.',
+ expect.any(Object)
+ )
+ expect(toastInfoMock).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/shared/useDaemonActions.tsx b/src/renderer/src/components/shared/useDaemonActions.tsx
index 9ae55cad0b1..8c386b5ef13 100644
--- a/src/renderer/src/components/shared/useDaemonActions.tsx
+++ b/src/renderer/src/components/shared/useDaemonActions.tsx
@@ -12,6 +12,11 @@ import {
DialogTitle
} from '../ui/dialog'
import { translate } from '@/i18n/i18n'
+import {
+ runKillAllTerminalSurfaces,
+ snapshotKillAllTerminalSurfaceIds,
+ type KillAllTerminalSurfacesSummary
+} from './kill-all-terminal-surfaces'
export type DaemonActionKind = 'restart' | 'killAll'
@@ -38,6 +43,90 @@ export type DaemonActionsApi = {
runConfirmed: () => void
}
+function showKillAllTerminalSurfacesResult(summary: KillAllTerminalSurfacesSummary): void {
+ const surfaceDescription =
+ summary.targetCount > 0
+ ? translate(
+ 'auto.components.shared.useDaemonActions.71a8d342b0',
+ 'Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.',
+ {
+ value0: summary.absentTargetCount,
+ value1: summary.targetCount,
+ value2: summary.failedCloseAttemptCount,
+ value3: summary.exactKillAcceptedCount,
+ value4: summary.exactKillRejectedCount
+ }
+ )
+ : null
+ const daemonDescription =
+ summary.daemon.status === 'rejected'
+ ? translate(
+ 'auto.components.shared.useDaemonActions.2e57c1a940',
+ 'The daemon shutdown result is unverified because its management request failed.'
+ )
+ : translate(
+ 'auto.components.shared.useDaemonActions.993af6052c',
+ 'Daemon management reported exited: {{value0}}/{{value1}}; still present before exact cleanup: {{value2}}.',
+ {
+ value0: summary.daemon.killedCount,
+ value1: summary.daemon.killedCount + summary.daemon.remainingCount,
+ value2: summary.daemon.remainingCount
+ }
+ )
+ const description = [surfaceDescription, daemonDescription].filter(Boolean).join(' ')
+ if (summary.daemon.status === 'rejected') {
+ toast.error(
+ translate(
+ 'auto.components.shared.useDaemonActions.1f0d8ac762',
+ 'Terminal cleanup finished with errors.'
+ ),
+ { description }
+ )
+ return
+ }
+ if (summary.failedCloseAttemptCount > 0 || summary.exactKillRejectedCount > 0) {
+ toast.error(
+ translate(
+ 'auto.components.shared.useDaemonActions.1f0d8ac762',
+ 'Terminal cleanup finished with errors.'
+ ),
+ { description }
+ )
+ return
+ }
+ if (summary.daemon.remainingCount > 0) {
+ toast.warning(
+ translate(
+ 'auto.components.shared.useDaemonActions.80b6ea14cf',
+ 'Terminal cleanup finished with warnings.'
+ ),
+ { description }
+ )
+ return
+ }
+ if (summary.targetCount === 0 && summary.daemon.killedCount === 0) {
+ toast.info(
+ translate(
+ 'auto.components.shared.useDaemonActions.47cd2a50e9',
+ 'No sessions or terminal tabs were reported.'
+ )
+ )
+ return
+ }
+ toast.success(
+ summary.targetCount > 0
+ ? translate(
+ 'auto.components.shared.useDaemonActions.c34fb1098d',
+ 'Terminal tabs closed and shutdown requested.'
+ )
+ : translate(
+ 'auto.components.shared.useDaemonActions.d9657ac204',
+ 'Terminal session shutdown requested.'
+ ),
+ { description }
+ )
+}
+
export function useDaemonActions(callbacks?: DaemonActionCallbacks): DaemonActionsApi {
const [pending, setPending] = useState(null)
const [busyKind, setBusyKind] = useState(null)
@@ -83,61 +172,26 @@ export function useDaemonActions(callbacks?: DaemonActionCallbacks): DaemonActio
}, [callbacks, clearPendingAction, mountedRef])
const runKillAll = useCallback(async () => {
+ // Why: confirmation covers the surface identities visible now; taking this
+ // before callbacks or awaits keeps later-created terminal tabs out of scope.
+ const targetSurfaceIds = snapshotKillAllTerminalSurfaceIds()
setBusyKind('killAll')
callbacks?.onKillAllStart?.()
try {
- const { killedCount, remainingCount } = await window.api.pty.management.killAll()
- if (remainingCount > 0 && killedCount > 0) {
- toast.warning(
- translate(
- 'auto.components.shared.useDaemonActions.fe2ab66d45',
- 'Killed {{value0}} of {{value1}} sessions. {{value2}} refused to exit.',
- { value0: killedCount, value1: killedCount + remainingCount, value2: remainingCount }
- )
- )
- } else if (killedCount === 1) {
- toast.success(
- translate(
- 'auto.components.shared.useDaemonActions.87412c2a68',
- 'Killed {{value0}} session.',
- { value0: killedCount }
- )
- )
- } else if (killedCount > 0) {
- toast.success(
- translate(
- 'auto.components.shared.useDaemonActions.a2f040ac1c',
- 'Killed {{value0}} sessions.',
- { value0: killedCount }
- )
- )
- } else if (remainingCount === 0) {
- toast.info(
- translate('auto.components.shared.useDaemonActions.baad8cd651', 'No sessions running.')
- )
- } else if (remainingCount === 1) {
- toast.error(
- translate(
- 'auto.components.shared.useDaemonActions.63520148e2',
- '{{value0}} session refused to exit.',
- { value0: remainingCount }
- )
- )
- } else {
- toast.error(
- translate(
- 'auto.components.shared.useDaemonActions.cc0a26cb14',
- '{{value0}} sessions refused to exit.',
- { value0: remainingCount }
- )
- )
+ const summary = await runKillAllTerminalSurfaces(targetSurfaceIds)
+ if (summary.daemon.status === 'rejected' && mountedRef.current) {
+ callbacks?.onKillAllError?.()
}
+ showKillAllTerminalSurfacesResult(summary)
} catch (err) {
if (mountedRef.current) {
callbacks?.onKillAllError?.()
}
toast.error(
- translate('auto.components.shared.useDaemonActions.2b4efdc162', 'Couldn’t kill sessions.'),
+ translate(
+ 'auto.components.shared.useDaemonActions.e8f25bd903',
+ 'Couldn’t finish terminal cleanup.'
+ ),
{
description: err instanceof Error ? err.message : undefined
}
@@ -203,8 +257,8 @@ function getCopy(kind: DaemonActionKind): CopyShape {
description: (
<>
{translate(
- 'auto.components.shared.useDaemonActions.28c8e53176',
- "This force-quits every running terminal pane across all workspaces. Any unsaved work in those sessions is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone."
+ 'auto.components.shared.useDaemonActions.a702d4196e',
+ "This closes every terminal tab across all workspaces and requests shutdown for its current terminal sessions. Any unsaved terminal work is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone."
)}
>
),
diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx
index 5ec3354ec22..33de6b6eb1d 100644
--- a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx
+++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx
@@ -82,10 +82,7 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() {
onboarding,
hadProjectBeforeAdd
)
- activateAndRevealWorktree(folderWorktree.id, {
- sidebarRevealBehavior: 'auto',
- ...(startup ? { startup } : {})
- })
+ activateAndRevealWorktree(folderWorktree.id, startup ? { startup } : undefined)
}
} catch (err) {
// This code path calls addRemote directly (not through the store),
diff --git a/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx b/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx
index f3580472439..66409526770 100644
--- a/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx
+++ b/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx
@@ -145,9 +145,7 @@ describe('ProjectAddedDialog', () => {
expect(markup).toBe('')
expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith('repo-1')
- expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('repo-1::folder', {
- sidebarRevealBehavior: 'auto'
- })
+ expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('repo-1::folder')
expect(mocks.state.closeModal).toHaveBeenCalledTimes(1)
expect(mocks.finishProjectAddWithDefaultCheckout).not.toHaveBeenCalled()
})
diff --git a/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx b/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx
index 6532a39cae8..34f53ffd6ac 100644
--- a/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx
+++ b/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx
@@ -81,7 +81,7 @@ export default function ProjectAddedDialog(): null {
}
const folderWorktree = useAppStore.getState().worktreesByRepo[repoId]?.[0]
if (folderWorktree) {
- activateAndRevealWorktree(folderWorktree.id, { sidebarRevealBehavior: 'auto' })
+ activateAndRevealWorktree(folderWorktree.id)
}
closeModal()
})()
diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx
index 7bfda38d231..033cb286dfa 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx
@@ -36,7 +36,7 @@ function revealCompactAgentCard(agentListRoot: HTMLElement | null): void {
if (!(sidebarElement instanceof HTMLElement) || !worktreeOptionElement) {
return
}
- revealElementInScrollContainer(sidebarElement, worktreeOptionElement, 'auto')
+ revealElementInScrollContainer(sidebarElement, worktreeOptionElement)
}
type Props = {
@@ -238,7 +238,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
// Why: defer the reveal scroll out of the expand commit. Running it inline
// forces a synchronous sidebar layout that blocks the animation's opening
// frames (a visible jump); next-frame keeps the open smooth and the
- // ScrollBehavior 'auto' still lands before the height transition finishes.
+ // instant reveal still lands before the height transition finishes.
const handle = requestAnimationFrame(() => {
revealCompactAgentCard(compactAgentListRootRef.current)
})
diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx
index 7b8aaab26d3..a1542323e62 100644
--- a/src/renderer/src/components/sidebar/WorktreeList.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeList.tsx
@@ -100,6 +100,7 @@ import {
type RenderRow
} from './worktree-list-virtual-rows'
import {
+ getElementCenteringScrollPadding,
revealElementInScrollContainer,
WORKTREE_SIDEBAR_REVEAL_TOP_INSET
} from './worktree-sidebar-reveal'
@@ -287,6 +288,7 @@ import {
import { getFolderWorkspaceCardPrDisplay } from './folder-workspace-card-pr-display'
export {
+ getCenteringScrollPadding,
getScrollTopToRevealBounds,
WORKTREE_SIDEBAR_REVEAL_TOP_INSET
} from './worktree-sidebar-reveal'
@@ -470,31 +472,15 @@ function markSidebarWorktreeActiveImmediately(worktreeId: string, primaryRowKey?
}
}
-function revealMountedWorktreeElement(
- container: HTMLElement,
- worktreeId: string,
- behavior: ScrollBehavior,
- optionId?: string
-): HTMLElement | null {
- const element = optionId
- ? document.getElementById(optionId)
- : getMountedWorktreeOptions(worktreeId, container)[0]
- if (!element || !container.contains(element)) {
- return null
- }
- return revealElementInScrollContainer(container, element, behavior) ? element : null
-}
-
function revealMountedSidebarRowElement(
container: HTMLElement,
- rowKey: string,
- behavior: ScrollBehavior
+ rowKey: string
): HTMLElement | null {
const element = document.getElementById(getWorktreeOptionId(rowKey))
if (!element || !container.contains(element)) {
return null
}
- return revealElementInScrollContainer(container, element, behavior) ? element : null
+ return revealElementInScrollContainer(container, element) ? element : null
}
function getRenderRowSidebarKey(row: RenderRow): string | null {
@@ -1336,6 +1322,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const [worktreeDragState, setWorktreeDragState] = useState(
WORKTREE_ROW_DRAG_INITIAL_STATE
)
+ const [centeringScrollPadding, setCenteringScrollPadding] = useState<{
+ targetKey: string
+ start: number
+ end: number
+ } | null>(null)
const [pendingRevealRetryTick, setPendingRevealRetryTick] = useState(0)
const [documentVisibilityRevision, setDocumentVisibilityRevision] = useState(0)
const [highlightedRevealRowKey, setHighlightedRevealRowKey] = useState(null)
@@ -1985,6 +1976,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
),
overscan: 10,
gap: 6,
+ paddingStart: centeringScrollPadding?.start ?? 0,
+ paddingEnd: centeringScrollPadding?.end ?? 0,
// Why: the active sticky group header is rendered inside the virtual list,
// so TanStack's scroll math needs the same top inset as the exact DOM reveal.
scrollPaddingStart: WORKTREE_SIDEBAR_REVEAL_TOP_INSET,
@@ -2005,6 +1998,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
// TanStack's default correction writes scrollTop in that path, which feels
// like rubber-banding. Structural mutations still use our explicit anchor
// restore after direct scroll input has settled.
+ // TODO(scroll-origin-migration): this wall-clock suppression misclassifies
+ // under main-thread jank; migrate to programmaticScrollMarks + restoreSignal
+ // (see CombinedDiffViewer) once that wiring is validated for the sidebar.
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) =>
shouldAdjustWorktreeSidebarMeasuredRowScroll({
isScrolling: instance.isScrolling,
@@ -2132,14 +2128,35 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
clearPendingRevealWorktreeId()
}
}
- const revealedOption = container
- ? revealMountedWorktreeElement(
- container,
- pendingRevealWorktree.worktreeId,
- pendingRevealWorktree.behavior,
- getRenderRowOptionId(targetRow, pendingRevealWorktree.worktreeId)
- )
- : null
+ const optionId = getRenderRowOptionId(targetRow, pendingRevealWorktree.worktreeId)
+ const centeringTargetKey = `worktree:${optionId ?? pendingRevealWorktree.worktreeId}`
+ if (centeringScrollPadding && centeringScrollPadding.targetKey !== centeringTargetKey) {
+ setCenteringScrollPadding(null)
+ retryExactRevealOnNextFrame()
+ return
+ }
+ const option = optionId
+ ? document.getElementById(optionId)
+ : getMountedWorktreeOptions(pendingRevealWorktree.worktreeId, container)[0]
+ const mountedOption = container && option && container.contains(option) ? option : null
+ if (container && mountedOption) {
+ const additionalPadding = getElementCenteringScrollPadding(container, mountedOption)
+ if (additionalPadding && (additionalPadding.start > 0 || additionalPadding.end > 0)) {
+ // Why: the first and last virtual rows need temporary boundary space
+ // or the browser clamps their centered position to the list edge.
+ setCenteringScrollPadding({
+ targetKey: centeringTargetKey,
+ start: (centeringScrollPadding?.start ?? 0) + additionalPadding.start,
+ end: (centeringScrollPadding?.end ?? 0) + additionalPadding.end
+ })
+ retryExactRevealOnNextFrame()
+ return
+ }
+ }
+ const revealedOption =
+ container && mountedOption && revealElementInScrollContainer(container, mountedOption)
+ ? mountedOption
+ : null
if (revealedOption) {
if (pendingRevealWorktree.highlight) {
const revealedRowKey =
@@ -2211,6 +2228,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
pendingRevealRetryTick,
flashRevealedRow,
setRenamingWorktreeId,
+ centeringScrollPadding,
schedulePendingRevealFrame,
cancelPendingRevealFrames
])
@@ -2294,12 +2312,16 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}
const container = scrollRef.current
+ // Why: only clear stale boundary padding here. While a worktree reveal is
+ // still pending, clearing would wipe the padding it just accumulated and
+ // its boundary target could exhaust retries without ever centering.
+ if (centeringScrollPadding && !pendingRevealWorktree) {
+ setCenteringScrollPadding(null)
+ retryExactRevealOnNextFrame()
+ return
+ }
const revealedElement = container
- ? revealMountedSidebarRowElement(
- container,
- pendingRevealSidebarRow.rowKey,
- pendingRevealSidebarRow.behavior
- )
+ ? revealMountedSidebarRowElement(container, pendingRevealSidebarRow.rowKey)
: null
if (revealedElement) {
if (pendingRevealSidebarRow.highlight) {
@@ -2323,6 +2345,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}
}, [
pendingRevealSidebarRow,
+ pendingRevealWorktree,
repoMap,
projectGroups,
projectGrouping,
@@ -2334,6 +2357,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
pendingRevealRetryTick,
flashRevealedRow,
clearPendingRevealSidebarRow,
+ centeringScrollPadding,
schedulePendingRevealFrame,
cancelPendingRevealFrames
])
@@ -6657,7 +6681,6 @@ const WorktreeList = React.memo(function WorktreeList({
{ target: { type: 'sidebar-row' } }
>
revealSidebarRow(detail.target.rowKey, {
- behavior: 'smooth',
highlight: sidebarDetail.highlight !== false
})
return
@@ -6679,7 +6702,6 @@ const WorktreeList = React.memo(function WorktreeList({
clearFilters()
}
revealWorktreeInSidebar(currentSidebarWorktreeId, {
- behavior: 'smooth',
highlight: true,
beginRename: (detail as { beginRename?: boolean } | undefined)?.beginRename === true
})
diff --git a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts
index 51a5ad755b5..83caaaa6d15 100644
--- a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts
+++ b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts
@@ -213,9 +213,7 @@ describe('useCreateRepo default-checkout handoff', () => {
kind: 'git'
})
expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id)
- expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(worktree.id, {
- sidebarRevealBehavior: 'auto'
- })
+ expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(worktree.id)
expect(mocks.markOnboardingProjectAdded).toHaveBeenCalledWith('addedFolder')
expect(closeModal).toHaveBeenCalled()
expect(mocks.onGitRepoReady).not.toHaveBeenCalled()
diff --git a/src/renderer/src/components/sidebar/useCreateRepo.ts b/src/renderer/src/components/sidebar/useCreateRepo.ts
index abf35db7e66..eb42f347e6f 100644
--- a/src/renderer/src/components/sidebar/useCreateRepo.ts
+++ b/src/renderer/src/components/sidebar/useCreateRepo.ts
@@ -190,7 +190,7 @@ export function useCreateRepo(
}
const folderWorktree = useAppStore.getState().worktreesByRepo[repo.id]?.[0]
if (folderWorktree) {
- activateAndRevealWorktree(folderWorktree.id, { sidebarRevealBehavior: 'auto' })
+ activateAndRevealWorktree(folderWorktree.id)
}
await markOnboardingProjectAdded('addedFolder')
closeModal()
diff --git a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts
index 924a4edc88a..f9a5a5f62ce 100644
--- a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import {
countRecordKeysByReference,
+ getCenteringScrollPadding,
getScrollTopToRevealBounds,
resolvePendingSidebarReveal,
WORKTREE_SIDEBAR_REVEAL_TOP_INSET,
@@ -43,8 +44,11 @@ const makeImportedCardRow = (): Extract
- ({ scrollTop, clientHeight }) as HTMLElement
+const makeScrollContainer = (
+ scrollTop: number,
+ clientHeight: number,
+ scrollHeight = 1_000
+): HTMLElement => ({ scrollTop, clientHeight, scrollHeight }) as HTMLElement
describe('shouldAdjustWorktreeSidebarMeasuredRowScroll', () => {
it('counts record keys once per object reference', () => {
@@ -121,64 +125,64 @@ describe('shouldAdjustWorktreeSidebarMeasuredRowScroll', () => {
})
describe('getScrollTopToRevealBounds', () => {
- it('treats the sticky header as occluding the viewport top', () => {
- const container = makeScrollContainer(100, 400)
+ it('requests leading space when the first target would be clamped above center', () => {
+ const container = makeScrollContainer(0, 400)
expect(
- getScrollTopToRevealBounds(
+ getCenteringScrollPadding(
container,
{
- start: 100,
- end: 216
- },
- GROUP_HEADER_ROW_HEIGHT
- )
- ).toBe(72)
- })
-
- it('includes extra reveal clearance for the highlight ring', () => {
- const container = makeScrollContainer(100, 400)
-
- expect(
- getScrollTopToRevealBounds(
- container,
- {
- start: 100,
- end: 216
+ start: 34,
+ end: 74
},
WORKTREE_SIDEBAR_REVEAL_TOP_INSET
)
- ).toBe(66)
+ ).toEqual({ start: 163, end: 0 })
})
- it('does not scroll when the bounds are below the sticky header', () => {
+ it('centers a fully visible target', () => {
const container = makeScrollContainer(100, 400)
expect(
getScrollTopToRevealBounds(
container,
{
- start: 128,
- end: 244
+ start: 300,
+ end: 400
},
- GROUP_HEADER_ROW_HEIGHT
+ WORKTREE_SIDEBAR_REVEAL_TOP_INSET
)
- ).toBeNull()
+ ).toBe(133)
})
- it('keeps the viewport bottom independent of the sticky header inset', () => {
+ it('centers within the area below the sticky header', () => {
const container = makeScrollContainer(100, 400)
expect(
getScrollTopToRevealBounds(
+ container,
+ {
+ start: 300,
+ end: 400
+ },
+ GROUP_HEADER_ROW_HEIGHT
+ )
+ ).toBe(136)
+ })
+
+ it('requests trailing space when the last target would be clamped below center', () => {
+ const container = makeScrollContainer(100, 400, 500)
+
+ expect(
+ getCenteringScrollPadding(
container,
{
start: 430,
- end: 520
+ end: 470
},
- GROUP_HEADER_ROW_HEIGHT
+ WORKTREE_SIDEBAR_REVEAL_TOP_INSET
)
- ).toBe(120)
+ ).toEqual({ start: 0, end: 133 })
})
})
diff --git a/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts b/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts
index 933261f20a4..850fc45704d 100644
--- a/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts
@@ -8,15 +8,17 @@ describe('getScrollTopToRevealBounds', () => {
clientHeight
}) as HTMLElement
- it('scrolls upward to reveal a mounted current workspace card above the viewport', () => {
- expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 60, end: 120 })).toBe(60)
+ it('centers a mounted current workspace card that starts above the viewport', () => {
+ // Raw result may be negative; boundary reveals convert the deficit into
+ // temporary paddingStart instead of clamping (see getCenteringScrollPadding).
+ expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 60, end: 120 })).toBe(-10)
})
- it('scrolls downward to reveal a mounted current workspace card below the viewport', () => {
- expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 250, end: 340 })).toBe(140)
+ it('centers a mounted current workspace card that starts below the viewport', () => {
+ expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 250, end: 340 })).toBe(195)
})
- it('does not scroll when the current workspace card is already fully visible', () => {
- expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 125, end: 260 })).toBeNull()
+ it('recenters a card that is already fully visible', () => {
+ expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 125, end: 260 })).toBe(92.5)
})
})
diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-reveal.ts b/src/renderer/src/components/sidebar/worktree-sidebar-reveal.ts
index 38b158532af..765efc2eabc 100644
--- a/src/renderer/src/components/sidebar/worktree-sidebar-reveal.ts
+++ b/src/renderer/src/components/sidebar/worktree-sidebar-reveal.ts
@@ -9,6 +9,11 @@ type SidebarRevealBounds = {
end: number
}
+type SidebarCenteringScrollPadding = {
+ start: number
+ end: number
+}
+
function getElementScrollBounds(container: HTMLElement, element: Element): SidebarRevealBounds {
const containerRect = container.getBoundingClientRect()
const elementRect = element.getBoundingClientRect()
@@ -22,24 +27,40 @@ export function getScrollTopToRevealBounds(
container: HTMLElement,
bounds: SidebarRevealBounds,
topInset = 0
-): number | null {
+): number {
const viewportTopInset = Math.max(0, Math.min(container.clientHeight, topInset))
- const viewportTop = container.scrollTop + viewportTopInset
- const viewportBottom = container.scrollTop + container.clientHeight
- if (bounds.start < viewportTop) {
- return bounds.start - viewportTopInset
- }
- if (bounds.end > viewportBottom) {
- return bounds.end - container.clientHeight
- }
- return null
+ // Why: the sticky header reduces the usable viewport, so center within the
+ // visible area below it instead of behind it.
+ const targetCenter = bounds.start + (bounds.end - bounds.start) / 2
+ const viewportCenterOffset = (viewportTopInset + container.clientHeight) / 2
+ return targetCenter - viewportCenterOffset
}
-export function revealElementInScrollContainer(
+export function getCenteringScrollPadding(
+ container: HTMLElement,
+ bounds: SidebarRevealBounds,
+ topInset = 0
+): SidebarCenteringScrollPadding {
+ const desiredScrollTop = getScrollTopToRevealBounds(container, bounds, topInset)
+ const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight)
+ return {
+ start: Math.ceil(Math.max(0, -desiredScrollTop)),
+ end: Math.ceil(Math.max(0, desiredScrollTop - maxScrollTop))
+ }
+}
+
+export function getElementCenteringScrollPadding(
container: HTMLElement,
element: Element,
- behavior: ScrollBehavior
-): boolean {
+ topInset = WORKTREE_SIDEBAR_REVEAL_TOP_INSET
+): SidebarCenteringScrollPadding | null {
+ if (!container.contains(element)) {
+ return null
+ }
+ return getCenteringScrollPadding(container, getElementScrollBounds(container, element), topInset)
+}
+
+export function revealElementInScrollContainer(container: HTMLElement, element: Element): boolean {
if (!container.contains(element)) {
return false
}
@@ -48,17 +69,8 @@ export function revealElementInScrollContainer(
getElementScrollBounds(container, element),
WORKTREE_SIDEBAR_REVEAL_TOP_INSET
)
- if (nextScrollTop === null) {
- return true
- }
- // Why: honor the user's reduced-motion preference by jumping instantly instead of
- // animating a smooth scroll (also makes the reveal deterministic in headless
- // environments that never tick the smooth-scroll animation).
- const prefersReducedMotion =
- typeof window !== 'undefined' &&
- window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true
- const resolvedBehavior: ScrollBehavior =
- behavior === 'smooth' && prefersReducedMotion ? 'auto' : behavior
- container.scrollTo({ top: Math.max(0, nextScrollTop), behavior: resolvedBehavior })
+ // Why: sidebar reveal is a focus handoff, so reposition immediately instead
+ // of making the user track an animated list.
+ container.scrollTop = Math.max(0, nextScrollTop)
return true
}
diff --git a/src/renderer/src/components/task-page-linear-issue-empty-state.test.ts b/src/renderer/src/components/task-page-linear-issue-empty-state.test.ts
new file mode 100644
index 00000000000..914d24db855
--- /dev/null
+++ b/src/renderer/src/components/task-page-linear-issue-empty-state.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest'
+import {
+ resolveLinearIssueEmptyKind,
+ shouldOfferLinearIssueFetchMore
+} from './task-page-linear-issue-empty-state'
+import { emptyLinearIssueAttributeFilter } from '../../../shared/linear-issue-attribute-filter'
+
+describe('task-page-linear-issue-empty-state', () => {
+ it('prefers context, then search, then server filter, then client team, then unfiltered', () => {
+ expect(
+ resolveLinearIssueEmptyKind({
+ hasContextLabel: true,
+ searchActive: true,
+ attributeFilter: emptyLinearIssueAttributeFilter(),
+ serverIssueCount: 0,
+ filteredIssueCount: 0
+ })
+ ).toBe('context')
+
+ expect(
+ resolveLinearIssueEmptyKind({
+ hasContextLabel: false,
+ searchActive: true,
+ attributeFilter: {
+ stateIds: ['s'],
+ priorities: [],
+ assignee: null,
+ labelIds: []
+ },
+ serverIssueCount: 0,
+ filteredIssueCount: 0
+ })
+ ).toBe('search')
+
+ expect(
+ resolveLinearIssueEmptyKind({
+ hasContextLabel: false,
+ searchActive: false,
+ attributeFilter: {
+ stateIds: ['s'],
+ priorities: [],
+ assignee: null,
+ labelIds: []
+ },
+ serverIssueCount: 0,
+ filteredIssueCount: 0
+ })
+ ).toBe('server-attribute-filter')
+
+ expect(
+ resolveLinearIssueEmptyKind({
+ hasContextLabel: false,
+ searchActive: false,
+ attributeFilter: emptyLinearIssueAttributeFilter(),
+ serverIssueCount: 3,
+ filteredIssueCount: 0
+ })
+ ).toBe('client-team')
+
+ expect(
+ resolveLinearIssueEmptyKind({
+ hasContextLabel: false,
+ searchActive: false,
+ attributeFilter: emptyLinearIssueAttributeFilter(),
+ serverIssueCount: 0,
+ filteredIssueCount: 0
+ })
+ ).toBe('unfiltered-scope')
+ })
+
+ it('offers fetch more only for client-team thinning with server hasMore', () => {
+ expect(shouldOfferLinearIssueFetchMore({ emptyKind: 'client-team', serverHasMore: true })).toBe(
+ true
+ )
+ expect(
+ shouldOfferLinearIssueFetchMore({
+ emptyKind: 'server-attribute-filter',
+ serverHasMore: true
+ })
+ ).toBe(false)
+ })
+})
diff --git a/src/renderer/src/components/task-page-linear-issue-empty-state.ts b/src/renderer/src/components/task-page-linear-issue-empty-state.ts
new file mode 100644
index 00000000000..a62370a1130
--- /dev/null
+++ b/src/renderer/src/components/task-page-linear-issue-empty-state.ts
@@ -0,0 +1,46 @@
+import {
+ isEmptyLinearIssueAttributeFilter,
+ type LinearIssueAttributeFilter
+} from '../../../shared/linear-issue-attribute-filter'
+
+export type LinearIssueEmptyKind =
+ | 'context'
+ | 'search'
+ | 'server-attribute-filter'
+ | 'client-team'
+ | 'unfiltered-scope'
+ | null
+
+export function resolveLinearIssueEmptyKind(options: {
+ hasContextLabel: boolean
+ searchActive: boolean
+ attributeFilter: LinearIssueAttributeFilter
+ serverIssueCount: number
+ filteredIssueCount: number
+}): LinearIssueEmptyKind {
+ if (options.serverIssueCount > 0 && options.filteredIssueCount === 0) {
+ return 'client-team'
+ }
+ if (options.serverIssueCount > 0) {
+ return null
+ }
+ if (options.hasContextLabel) {
+ return 'context'
+ }
+ if (options.searchActive) {
+ return 'search'
+ }
+ if (!isEmptyLinearIssueAttributeFilter(options.attributeFilter)) {
+ return 'server-attribute-filter'
+ }
+ return 'unfiltered-scope'
+}
+
+export function shouldOfferLinearIssueFetchMore(options: {
+ emptyKind: LinearIssueEmptyKind
+ serverHasMore: boolean
+}): boolean {
+ // Why: Fetch more is only honest for client-team thinning of a larger server
+ // window. Server-filtered empties must not invite more pages of the same empty set.
+ return options.emptyKind === 'client-team' && options.serverHasMore
+}
diff --git a/src/renderer/src/components/task-page-linear-issue-request.test.ts b/src/renderer/src/components/task-page-linear-issue-request.test.ts
new file mode 100644
index 00000000000..71cdbca617f
--- /dev/null
+++ b/src/renderer/src/components/task-page-linear-issue-request.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest'
+import {
+ buildLinearIssueListReadArgs,
+ buildLinearIssueListRequestSignature,
+ isLinearIssueSearchActive,
+ shouldForceLinearIssueListRead,
+ teamDerivedFacetsForPrimaryTeamChange
+} from './task-page-linear-issue-request'
+import type { LinearIssueAttributeFilter } from '../../../shared/linear-issue-attribute-filter'
+
+const filter: LinearIssueAttributeFilter = {
+ stateIds: ['s1'],
+ priorities: [1],
+ assignee: { kind: 'unassigned' },
+ labelIds: ['l1']
+}
+
+describe('task-page-linear-issue-request', () => {
+ it('treats immediate or applied search as active', () => {
+ expect(isLinearIssueSearchActive('q', '')).toBe(true)
+ expect(isLinearIssueSearchActive('', 'q')).toBe(true)
+ expect(isLinearIssueSearchActive(' ', ' ')).toBe(false)
+ })
+
+ it('omits attribute filters from list read args while search is active', () => {
+ expect(
+ buildLinearIssueListReadArgs({
+ limit: 36,
+ attributeFilter: filter,
+ searchActive: true
+ }).attributeFilter
+ ).toBeUndefined()
+ expect(
+ buildLinearIssueListReadArgs({
+ limit: 36,
+ attributeFilter: filter,
+ searchActive: false
+ }).attributeFilter
+ ).toEqual(filter)
+ })
+
+ it('includes source scope and canonical signature in the request identity', () => {
+ const signature = buildLinearIssueListRequestSignature({
+ workspaceId: 'ws-1',
+ limit: 36,
+ attributeFilter: filter
+ })
+ expect(signature).toContain('local::ws-1::list::all::36::')
+ expect(signature).toContain('"stateIds":["s1"]')
+ })
+
+ it('forces a read when the filter signature changes', () => {
+ expect(
+ shouldForceLinearIssueListRead({
+ previousFilterSignature: 'a',
+ nextFilterSignature: 'b',
+ refreshForced: false
+ })
+ ).toBe(true)
+ expect(
+ shouldForceLinearIssueListRead({
+ previousFilterSignature: 'a',
+ nextFilterSignature: 'a',
+ refreshForced: false
+ })
+ ).toBe(false)
+ })
+
+ it('clears team-derived facets while preserving priority', () => {
+ expect(teamDerivedFacetsForPrimaryTeamChange(filter)).toEqual({
+ stateIds: [],
+ priorities: [1],
+ assignee: null,
+ labelIds: []
+ })
+ })
+})
diff --git a/src/renderer/src/components/task-page-linear-issue-request.ts b/src/renderer/src/components/task-page-linear-issue-request.ts
new file mode 100644
index 00000000000..ca19149ff33
--- /dev/null
+++ b/src/renderer/src/components/task-page-linear-issue-request.ts
@@ -0,0 +1,80 @@
+import {
+ isEmptyLinearIssueAttributeFilter,
+ linearIssueAttributeFilterSignature,
+ type LinearIssueAttributeFilter
+} from '../../../shared/linear-issue-attribute-filter'
+import {
+ getTaskSourceCacheScope,
+ type TaskSourceContext
+} from '../../../shared/task-source-context'
+import type { LinearIssueListReadArgs } from '../store/slices/linear'
+
+export function isLinearIssueSearchActive(immediateQuery: string, appliedQuery: string): boolean {
+ return immediateQuery.trim().length > 0 || appliedQuery.trim().length > 0
+}
+
+export function buildLinearIssueListReadArgs(options: {
+ filter?: 'assigned' | 'created' | 'all' | 'completed'
+ limit: number
+ attributeFilter: LinearIssueAttributeFilter
+ searchActive: boolean
+ /** Concrete workspace only; `all` must never send workspace-scoped facet ids. */
+ allowAttributeFilter?: boolean
+}): LinearIssueListReadArgs {
+ const attributeFilter =
+ options.searchActive ||
+ options.allowAttributeFilter === false ||
+ isEmptyLinearIssueAttributeFilter(options.attributeFilter)
+ ? undefined
+ : options.attributeFilter
+ return {
+ kind: 'list',
+ filter: options.filter ?? 'all',
+ limit: options.limit,
+ attributeFilter
+ }
+}
+
+export function buildLinearIssueListRequestSignature(options: {
+ sourceContext?: TaskSourceContext | null
+ workspaceId: string | null | undefined
+ filter?: 'assigned' | 'created' | 'all' | 'completed'
+ limit: number
+ attributeFilter: LinearIssueAttributeFilter
+ searchQuery?: string
+}): string {
+ const sourceScope = options.sourceContext
+ ? getTaskSourceCacheScope(options.sourceContext)
+ : 'local'
+ const workspace = options.workspaceId ?? 'default'
+ if (options.searchQuery && options.searchQuery.trim().length > 0) {
+ return `${sourceScope}::${workspace}::search::${options.searchQuery.trim()}`
+ }
+ const signature = linearIssueAttributeFilterSignature(options.attributeFilter)
+ return `${sourceScope}::${workspace}::list::${options.filter ?? 'all'}::${options.limit}::${signature}`
+}
+
+export function shouldForceLinearIssueListRead(options: {
+ previousFilterSignature: string
+ nextFilterSignature: string
+ refreshForced: boolean
+}): boolean {
+ if (options.refreshForced) {
+ return true
+ }
+ // Why: filter signature changes always need a current server read, even when
+ // returning to a previously cached signature that is still warm.
+ return options.previousFilterSignature !== options.nextFilterSignature
+}
+
+export function teamDerivedFacetsForPrimaryTeamChange(
+ current: LinearIssueAttributeFilter
+): LinearIssueAttributeFilter {
+ // Why: status/assignee/labels are team-scoped ids; priority is global 0..4.
+ return {
+ stateIds: [],
+ priorities: current.priorities,
+ assignee: null,
+ labelIds: []
+ }
+}
diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx
index 0e68af9126f..8e1c8f17d8d 100644
--- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx
@@ -11,6 +11,7 @@ import {
} from '../activity/activity-terminal-portal'
import TerminalPane from './TerminalPane'
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
+import { shouldMountBackgroundWorktreeTab } from '../terminal/background-terminal-worktree-mount'
import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut'
type TerminalOverlayAssignment = {
@@ -278,12 +279,16 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
worktreeId,
worktreePath,
isWorktreeActive,
- activityTerminalPortals = EMPTY_ACTIVITY_PORTALS
+ activityTerminalPortals = EMPTY_ACTIVITY_PORTALS,
+ backgroundMountTabIds = null
}: {
worktreeId: string
worktreePath: string
isWorktreeActive: boolean
activityTerminalPortals?: ActivityTerminalPortalTarget[]
+ /** Non-null for targeted background mounts: only these terminal tabs get a
+ * TerminalPane, so waking one slept agent does not connect every saved tab. */
+ backgroundMountTabIds?: ReadonlySet | null
}): React.JSX.Element | null {
const { terminalTabs, unifiedTabs, groups, activeGroupId } = useAppStore(
useShallow((state) => ({
@@ -352,34 +357,38 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
return (
<>
- {terminalTabs.map((terminalTab) => {
- const assignment = assignments.get(terminalTab.id)
- const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup)
- const isActive = Boolean(isVisible && assignment && assignment.groupId === activeGroupId)
- const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, {
- worktreeId,
- tabId: terminalTab.id
- })
- return (
-
+ {terminalTabs
+ .filter((terminalTab) =>
+ shouldMountBackgroundWorktreeTab(backgroundMountTabIds, terminalTab.id)
)
- })}
+ .map((terminalTab) => {
+ const assignment = assignments.get(terminalTab.id)
+ const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup)
+ const isActive = Boolean(isVisible && assignment && assignment.groupId === activeGroupId)
+ const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, {
+ worktreeId,
+ tabId: terminalTab.id
+ })
+ 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 63a28b7819a..9368f810cbf 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
@@ -174,6 +174,7 @@ type StoreState = {
agentStatusByPaneKey: Record
paneForegroundAgentByPaneKey: Record
sleepingAgentSessionsByPaneKey: Record
+ suppressedPtyExitIds: Record
agentLaunchConfigByPaneKey: Record
getAgentLaunchConfigForStatusEntry: ReturnType
getAgentLaunchConfigForStatusMetadata: ReturnType
@@ -748,6 +749,7 @@ describe('connectPanePty', () => {
agentStatusByPaneKey: {},
paneForegroundAgentByPaneKey: {},
sleepingAgentSessionsByPaneKey: {},
+ suppressedPtyExitIds: {},
agentLaunchConfigByPaneKey: {},
getAgentLaunchConfigForStatusEntry: vi.fn((entry: { paneKey: string }) => {
return mockStoreState.agentLaunchConfigByPaneKey[entry.paneKey]?.launchConfig
@@ -1830,6 +1832,7 @@ describe('connectPanePty', () => {
updatedAt: 1,
origin: 'worktree-sleep'
}
+ mockStoreState.suppressedPtyExitIds['tab-pty'] = true
const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as {
noteVisibilityResume: () => void
@@ -1898,9 +1901,10 @@ describe('connectPanePty', () => {
updatedAt: 1,
origin: 'worktree-sleep'
}
+ mockStoreState.suppressedPtyExitIds['tab-pty'] = true
const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as {
- wakeHibernatedAgentIfArmed: () => void
+ wakeHibernatedAgentIfArmed: (claimedProviderSessions?: Set) => string | null
dispose: () => void
}
await flushAsyncTicks()
@@ -1913,7 +1917,14 @@ describe('connectPanePty', () => {
// Still hidden: no reveal happened, so nothing respawned on exit.
expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit)
- binding.wakeHibernatedAgentIfArmed()
+ // The wake reports the claim it consumed so the dispatcher's generic
+ // resume never launches the same provider session into a second tab.
+ const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-bg'
+ expect(binding.wakeHibernatedAgentIfArmed(new Set([claimKey]))).toBeNull()
+ expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit)
+ const claimedProviderSessions = new Set()
+ expect(binding.wakeHibernatedAgentIfArmed(claimedProviderSessions)).toBe(claimKey)
+ expect(claimedProviderSessions).toEqual(new Set([claimKey]))
await flushAsyncTicks()
expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit)
@@ -1930,6 +1941,183 @@ describe('connectPanePty', () => {
expect(transport.connect.mock.calls.length).toBe(connectCallsAfterWake)
})
+ it('latches a navigation-free wake that lands before the hibernation kill arms the pane', async () => {
+ // Race (#7906): mobile opens the worktree after the sleeping record is
+ // written but before the suppressed kill's exit sets hibernatedWakePtyId.
+ // The wake is edge-triggered and the phone never reveals the desktop pane,
+ // so without a latch the wake would be dropped and the phone left staring
+ // at a frozen terminal.
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport('pty-pane-2')
+ transportFactoryQueue.push(transport)
+ const manager = createManager(1)
+ const deps = createDeps({
+ consumeSuppressedPtyExit: vi.fn(() => true),
+ isVisibleRef: { current: false }
+ })
+ const pane = createPane(2)
+ const paneKey = `tab-1:${leafIdForPane(2)}`
+ mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = {
+ paneKey,
+ tabId: 'tab-1',
+ worktreeId: 'wt-1',
+ agent: 'claude',
+ providerSession: { key: 'session_id', id: 'sess-hibernated-race' },
+ prompt: 'test prompt',
+ state: 'done',
+ capturedAt: 1,
+ updatedAt: 1,
+ origin: 'worktree-sleep'
+ }
+ mockStoreState.suppressedPtyExitIds['tab-pty'] = true
+
+ const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as {
+ wakeHibernatedAgentIfArmed: (claimedProviderSessions?: Set) => string | null
+ dispose: () => void
+ }
+ await flushAsyncTicks()
+ expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty')
+
+ // Wake arrives mid-kill: nothing is armed yet, but the pane must claim the
+ // session (suppressing the generic resume) and latch the request.
+ const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-race'
+ expect(binding.wakeHibernatedAgentIfArmed(new Set([claimKey]))).toBeNull()
+ const claimedProviderSessions = new Set()
+ expect(binding.wakeHibernatedAgentIfArmed(claimedProviderSessions)).toBe(claimKey)
+ expect(claimedProviderSessions).toEqual(new Set([claimKey]))
+ const connectCallsBeforeExit = transport.connect.mock.calls.length
+
+ const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined
+ onPtyExit?.('tab-pty')
+ await flushAsyncTicks()
+
+ // Arming consumed the latched wake — the --resume spawned with no reveal
+ // and no second wake event.
+ expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit)
+ const resumeConnectOptions = transport.connect.mock.calls.at(-1)?.[0] as
+ | { command?: string }
+ | undefined
+ expect(resumeConnectOptions?.command).toContain('--resume')
+ expect(resumeConnectOptions?.command).toContain('sess-hibernated-race')
+ })
+
+ it('keeps an in-place provider claim until the replacement PTY spawn settles', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport('pty-pane-2')
+ transportFactoryQueue.push(transport)
+ const deps = createDeps({
+ consumeSuppressedPtyExit: vi.fn(() => true),
+ isVisibleRef: { current: false }
+ })
+ const paneKey = `tab-1:${leafIdForPane(2)}`
+ mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = {
+ paneKey,
+ tabId: 'tab-1',
+ worktreeId: 'wt-1',
+ agent: 'claude',
+ providerSession: { key: 'session_id', id: 'sess-hibernated-inflight' },
+ prompt: 'test prompt',
+ state: 'done',
+ capturedAt: 1,
+ updatedAt: 1,
+ origin: 'worktree-sleep'
+ }
+ const binding = connectPanePty(
+ createPane(2) as never,
+ createManager(1) as never,
+ deps as never
+ ) as unknown as {
+ wakeHibernatedAgentIfArmed: () => string | null
+ }
+ await flushAsyncTicks()
+
+ const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined
+ onPtyExit?.('tab-pty')
+ await flushAsyncTicks()
+ const deferredSpawn = createDeferred()
+ transport.connect.mockImplementationOnce(() => deferredSpawn.promise)
+
+ const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-inflight'
+ expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey)
+ const connectCallsAfterFirstWake = transport.connect.mock.calls.length
+ expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey)
+ expect(transport.connect.mock.calls.length).toBe(connectCallsAfterFirstWake)
+
+ deferredSpawn.resolve('pty-resumed')
+ await flushAsyncTicks()
+ expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey)
+ })
+
+ it('re-arms the exact hibernation target after a replacement spawn fails', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport('pty-pane-2')
+ transportFactoryQueue.push(transport)
+ const paneKey = `tab-1:${leafIdForPane(2)}`
+ mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = {
+ paneKey,
+ tabId: 'tab-1',
+ worktreeId: 'wt-1',
+ agent: 'claude',
+ providerSession: { key: 'session_id', id: 'sess-hibernated-retry' },
+ prompt: 'test prompt',
+ state: 'done',
+ capturedAt: 1,
+ updatedAt: 1,
+ origin: 'worktree-sleep'
+ }
+ const binding = connectPanePty(
+ createPane(2) as never,
+ createManager(1) as never,
+ createDeps({
+ consumeSuppressedPtyExit: vi.fn(() => true),
+ isVisibleRef: { current: false }
+ }) as never
+ ) as unknown as { wakeHibernatedAgentIfArmed: () => string | null }
+ await flushAsyncTicks()
+
+ const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined
+ onPtyExit?.('tab-pty')
+ await flushAsyncTicks()
+ transport.connect.mockRejectedValueOnce(new Error('transient spawn failure'))
+
+ const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-retry'
+ expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey)
+ await flushAsyncTicks(20)
+ const connectCallsAfterFailure = transport.connect.mock.calls.length
+
+ expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey)
+ expect(transport.connect.mock.calls.length).toBe(connectCallsAfterFailure + 1)
+ })
+
+ it('does not latch a stale sleeping record beside an unsuppressed live PTY', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport('pty-pane-2')
+ transportFactoryQueue.push(transport)
+ const paneKey = `tab-1:${leafIdForPane(2)}`
+ mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = {
+ paneKey,
+ tabId: 'tab-1',
+ worktreeId: 'wt-1',
+ agent: 'claude',
+ providerSession: { key: 'session_id', id: 'sess-stale' },
+ prompt: 'test prompt',
+ state: 'done',
+ capturedAt: 1,
+ updatedAt: 1,
+ origin: 'worktree-sleep'
+ }
+ const binding = connectPanePty(
+ createPane(2) as never,
+ createManager(1) as never,
+ createDeps({ isVisibleRef: { current: false } }) as never
+ ) as unknown as {
+ wakeHibernatedAgentIfArmed: () => string | null
+ }
+ await flushAsyncTicks()
+
+ expect(binding.wakeHibernatedAgentIfArmed()).toBeNull()
+ })
+
it('auto-resumes a hibernated pane when its kill lands after the pane is already revealed', async () => {
// Race: the user reveals the background tab in the window between the
// coordinator confirming the candidate and the kill's exit arriving. The
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts
index df753a47114..e86afd8018e 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
@@ -88,6 +88,10 @@ import {
} from '@/lib/pane-manager/terminal-scroll-intent'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id'
+import {
+ getProviderSessionClaimKey,
+ isPassiveCompletedHibernationEvidence
+} from '@/lib/sleeping-agent-pane-ownership'
import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
import { createPaneForegroundAgentTracker } from './pane-foreground-agent-tracker'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
@@ -676,8 +680,10 @@ type PanePtyBinding = IDisposable & {
/** Navigation-free hibernation wake: fires the armed cold-restore --resume
* without the size-reassert/foreground-sample side effects of a real reveal.
* Used by the mobile wake fanout so a hidden hibernated pane resumes with no
- * desktop hidden→visible transition. */
- wakeHibernatedAgentIfArmed: () => void
+ * desktop hidden→visible transition. Returns the sleeping record's provider
+ * session claim key when this pane started (or latched) the in-place wake,
+ * so the follow-up generic resume never launches the same session twice. */
+ wakeHibernatedAgentIfArmed: (claimedProviderSessions?: Set) => string | null
/** Re-sample process identity when the pane gains intra-tab focus: the tab
* icon follows the active leaf, and a shell-marked entry on a still-running
* agent pane has no OSC boundary left to correct it. */
@@ -2079,35 +2085,80 @@ export function connectPanePty(
let spawnedFreshPtyId: string | null = null
// Why: hibernation suppresses its kill's exit while the pane is hidden, so
// onExit must not tear the pane down — but the pane still owes the user a
- // wake. Remember the hibernated ptyId; the visibility-resume hook consumes
- // it and relaunches the recorded agent session on next reveal.
- let hibernatedWakePtyId: string | null = null
- let wakeHibernatedAgentPane: (() => void) | null = null
+ // wake. Remember the hibernated PTY and exact record; the visibility-resume
+ // hook consumes both and cannot accidentally adopt a later stale record.
+ type HibernatedWakeTarget = { ptyId: string; record: SleepingAgentSessionRecord }
+ let hibernatedWakeTarget: HibernatedWakeTarget | null = null
+ let wakeHibernatedAgentPane: (() => Promise) | null = null
+ // Why: a mobile wake can land after the sleeping record is written but
+ // before the suppressed kill exit arms the wake target. The phone never
+ // reveals the desktop pane, so without a latch the edge-triggered wake would
+ // be dropped and the phone left on a frozen terminal.
+ let pendingHibernatedWakeTarget: HibernatedWakeTarget | null = null
+ // Why: transport.connect settles asynchronously. Repeated mobile activation
+ // must keep claiming this provider session until the replacement PTY either
+ // exists (and clears the sleep record) or the spawn fails and can be retried.
+ let hibernatedWakeInFlightClaimKey: string | null = null
// Why: reveal is the normal wake trigger, but a reveal that lands *during* the
// in-flight hibernation kill runs noteVisibilityResume before onExit arms the
// wake. Sharing the guarded consume lets both the reveal hook and the
// arm-time foreground check resume the pane exactly once.
- const consumeHibernatedAgentWake = (): void => {
- if (hibernatedWakePtyId === null || disposed) {
- return
+ const consumeHibernatedAgentWake = (claimedProviderSessions?: Set): string | null => {
+ const target = hibernatedWakeTarget
+ if (!target || disposed) {
+ return null
}
if (deps.paneTransportsRef.current.get(pane.id) !== transport) {
- return
+ return null
+ }
+ const currentRecord = getSleepingRecordForPane(useAppStore.getState())?.record
+ if (currentRecord !== target.record) {
+ hibernatedWakeTarget = null
+ pendingHibernatedWakeTarget = null
+ return null
}
const currentPtyId = transport.getPtyId()
// Why: a real pty:exit clears the transport's ptyId before onExit while a
// reconcile-driven exit leaves it bound; both mean "nothing respawned since
// hibernation". A different non-null id means another flow (e.g. an
// intentional restart) already rebound the pane — its spawn wins.
- if (currentPtyId !== null && currentPtyId !== hibernatedWakePtyId) {
- hibernatedWakePtyId = null
- return
+ if (currentPtyId !== null && currentPtyId !== target.ptyId) {
+ hibernatedWakeTarget = null
+ pendingHibernatedWakeTarget = null
+ return null
}
- hibernatedWakePtyId = null
+ if (!wakeHibernatedAgentPane) {
+ return null
+ }
+ const claimKey = getProviderSessionClaimKey(target.record)
+ if (claimedProviderSessions?.has(claimKey)) {
+ return null
+ }
+ // Why: one wake event can visit multiple mounted legacy/stable panes for
+ // the same provider session. Claim synchronously before any spawn starts.
+ claimedProviderSessions?.add(claimKey)
+ hibernatedWakeTarget = null
+ pendingHibernatedWakeTarget = null
+ hibernatedWakeInFlightClaimKey = claimKey
// Why: reveal is the wake signal for a hibernated pane. Resume the recorded
// agent session (or fall back to a fresh shell) instead of leaving the
// frozen frame with no PTY behind it.
- wakeHibernatedAgentPane?.()
+ void wakeHibernatedAgentPane()
+ .then((spawnedPtyId) => {
+ if (!spawnedPtyId) {
+ // Why: a transient replacement-spawn failure leaves the passive
+ // record owned by this pane. Re-arm the exact target so a later
+ // mobile open can retry instead of stranding the frozen session;
+ // consume revalidates disposal, binding, PTY, and record identity.
+ hibernatedWakeTarget = target
+ }
+ })
+ .finally(() => {
+ if (hibernatedWakeInFlightClaimKey === claimKey) {
+ hibernatedWakeInFlightClaimKey = null
+ }
+ })
+ return claimKey
}
const onExit = (ptyId: string): void => {
if (handledExitPtyId === ptyId) {
@@ -2153,20 +2204,34 @@ export function connectPanePty(
// Why: the action that suppressed the exit owns whether the leaf binding
// is a wake hint or should be discarded; runtime cleanup above is enough.
manager.setPaneGpuRendering(pane.id, true)
- if (getSleepingRecordForPane(useAppStore.getState())) {
+ const sleepingRecordEntry = getSleepingRecordForPane(useAppStore.getState())
+ if (
+ sleepingRecordEntry &&
+ isPassiveCompletedHibernationEvidence(sleepingRecordEntry.record)
+ ) {
// Why: hibernation killed this pane's PTY while hidden. The frozen TUI
// frame still has mouse-tracking/bracketed-paste armed, which silently
// eats every click and keystroke against a dead transport — disarm the
// modes now and arm the reveal-time wake.
replayIntoTerminal(pane, deps.replayingPanesRef, POST_REPLAY_MODE_RESET)
- hibernatedWakePtyId = ptyId
- if (deps.isVisibleRef.current) {
- // Why: a reveal that raced this kill already ran noteVisibilityResume
- // before the exit landed, so it saw nothing armed. Consume the wake
- // now (deferred off the exit handler) so a foreground pane still
- // resumes without needing a second hide/reveal.
- queueMicrotask(consumeHibernatedAgentWake)
+ hibernatedWakeTarget = { ptyId, record: sleepingRecordEntry.record }
+ const pendingWakeMatches =
+ pendingHibernatedWakeTarget?.ptyId === ptyId &&
+ pendingHibernatedWakeTarget.record === sleepingRecordEntry.record
+ if (pendingHibernatedWakeTarget && !pendingWakeMatches) {
+ pendingHibernatedWakeTarget = null
}
+ if (deps.isVisibleRef.current || pendingWakeMatches) {
+ // Why: a reveal (or a mobile wake) that raced this kill already ran
+ // before the exit landed, so it saw nothing armed. Consume the wake
+ // now (deferred off the exit handler) so the pane still resumes
+ // without needing a second hide/reveal or wake event.
+ queueMicrotask(() => {
+ consumeHibernatedAgentWake()
+ })
+ }
+ } else if (pendingHibernatedWakeTarget?.ptyId === ptyId) {
+ pendingHibernatedWakeTarget = null
}
return
}
@@ -3669,9 +3734,9 @@ export function connectPanePty(
const startFreshColdRestoreAgentResume = (
startup: ColdRestoreAgentResumeStartup | null = buildColdRestoreAgentResumeStartup(),
options: FreshSpawnOptions = {}
- ): void => {
+ ): Promise => {
applyColdRestoreAgentResumeStartup(startup)
- startFreshSpawn(startup, options)
+ return startFreshSpawn(startup, options)
}
// Why: the hibernation wake fires from noteVisibilityResume in the outer
// connection scope, long after this deferred-connect closure has run.
@@ -3751,7 +3816,7 @@ export function connectPanePty(
const startFreshSpawn = (
startupOverride?: PendingStartupCommand | null,
options: FreshSpawnOptions = {}
- ): void => {
+ ): Promise => {
clearPaneMode2031State()
clearHiddenOutputRestoreState()
prepareFreshShellViewportForSpawn(options)
@@ -3880,6 +3945,7 @@ export function connectPanePty(
// Why: split panes in the same tab can spawn concurrently. Key by pane
// as well as tab so a remount cannot attach to a sibling setup pane's PTY.
pendingSpawnByPaneKey.set(pendingSpawnKey, trackedPromise)
+ return trackedPromise
}
let foregroundRefreshRiskScanTail = ''
@@ -6251,8 +6317,44 @@ export function connectPanePty(
},
// Why: mobile wake reaches this pane while it stays hidden on the desktop, so
// it must consume only the armed hibernation wake — no size/foreground reads.
- wakeHibernatedAgentIfArmed() {
- consumeHibernatedAgentWake()
+ wakeHibernatedAgentIfArmed(claimedProviderSessions) {
+ if (hibernatedWakeInFlightClaimKey) {
+ if (claimedProviderSessions?.has(hibernatedWakeInFlightClaimKey)) {
+ return null
+ }
+ claimedProviderSessions?.add(hibernatedWakeInFlightClaimKey)
+ return hibernatedWakeInFlightClaimKey
+ }
+ const consumedClaimKey = consumeHibernatedAgentWake(claimedProviderSessions)
+ if (consumedClaimKey) {
+ return consumedClaimKey
+ }
+ // Why: wake arrived mid-hibernation-kill — the record exists but onExit
+ // has not armed the wake target yet (the transport is still bound to the
+ // dying PTY). Only the exact PTY already marked for suppressed shutdown
+ // may latch; a stale/manual record beside an ordinary live PTY must not.
+ const state = useAppStore.getState()
+ const recordEntry = getSleepingRecordForPane(state)
+ const currentPtyId = transport.getPtyId()
+ if (
+ recordEntry &&
+ isPassiveCompletedHibernationEvidence(recordEntry.record) &&
+ currentPtyId !== null &&
+ state.suppressedPtyExitIds[currentPtyId] === true &&
+ !disposed &&
+ hibernatedWakeTarget === null &&
+ deps.paneTransportsRef.current.get(pane.id) === transport &&
+ transport.getPtyId() === currentPtyId
+ ) {
+ const claimKey = getProviderSessionClaimKey(recordEntry.record)
+ if (claimedProviderSessions?.has(claimKey)) {
+ return null
+ }
+ claimedProviderSessions?.add(claimKey)
+ pendingHibernatedWakeTarget = { ptyId: currentPtyId, record: recordEntry.record }
+ return claimKey
+ }
+ return null
},
sampleForegroundAgentOnFocus() {
sampleVisiblePaneForegroundAgent()
diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts
index 8cd870aec13..35686a4abee 100644
--- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts
@@ -153,9 +153,7 @@ describe('forkAgentSessionFromPane', () => {
launchSource: 'terminal_context_menu'
})
)
- expect(mockActivateAndRevealWorktree).toHaveBeenCalledWith('wt-fork', {
- sidebarRevealBehavior: 'auto'
- })
+ expect(mockActivateAndRevealWorktree).toHaveBeenCalledWith('wt-fork')
expect(mockToast.success).toHaveBeenCalledWith(
'Top-level session fork opened in a new workspace'
)
@@ -346,9 +344,7 @@ describe('forkAgentSessionFromPane', () => {
undefined
)
expect(mockLaunchAgentInNewTab).not.toHaveBeenCalled()
- expect(mockActivateAndRevealWorktree).toHaveBeenCalledWith('wt-fork', {
- sidebarRevealBehavior: 'auto'
- })
+ expect(mockActivateAndRevealWorktree).toHaveBeenCalledWith('wt-fork')
expect(mockWriteClipboardText).toHaveBeenCalledWith(
expect.stringContaining('Assistant: here is the current plan')
)
diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts
index 6b36a638749..7f2d62b4782 100644
--- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts
@@ -266,7 +266,7 @@ export async function startAgentSessionFork(fork: PreparedAgentSessionFork): Pro
const forkWorktreeId = created.worktree.id
if (!fork.agent) {
- activateAndRevealWorktree(forkWorktreeId, { sidebarRevealBehavior: 'auto' })
+ activateAndRevealWorktree(forkWorktreeId)
return copyAgentSessionForkContext(fork)
}
await preflightForkAgentTrust({
@@ -287,7 +287,7 @@ export async function startAgentSessionFork(fork: PreparedAgentSessionFork): Pro
launchSource: 'terminal_context_menu',
...(launchPlatform ? { launchPlatform } : {})
})
- activateAndRevealWorktree(forkWorktreeId, { sidebarRevealBehavior: 'auto' })
+ activateAndRevealWorktree(forkWorktreeId)
if (!result) {
return copyAgentSessionForkContext(fork)
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 4f9d93ed934..a91514856f1 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
@@ -1760,9 +1760,18 @@ export function useTerminalPaneLifecycle({
return
}
for (const panePtyBinding of panePtyBindingsRef.current.values()) {
- ;(
- panePtyBinding as IDisposable & { wakeHibernatedAgentIfArmed?: () => void }
- ).wakeHibernatedAgentIfArmed?.()
+ const claimKey = (
+ panePtyBinding as IDisposable & {
+ wakeHibernatedAgentIfArmed?: (claimedProviderSessions?: Set) => string | null
+ }
+ ).wakeHibernatedAgentIfArmed?.(detail.wokenClaimKeys)
+ // Why: the dispatcher's follow-up generic resume must skip provider
+ // sessions this pane woke (or latched) in place — the sleeping record
+ // is only cleared after the in-place spawn succeeds, so without this
+ // the same session would resume twice.
+ if (claimKey) {
+ detail.wokenClaimKeys?.add(claimKey)
+ }
}
}
window.addEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWakeHibernatedAgents)
diff --git a/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts b/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts
index f2cef1eae04..9292eede164 100644
--- a/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts
+++ b/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts
@@ -1,6 +1,48 @@
import { describe, expect, it, vi } from 'vitest'
-import { addBackgroundMountedTerminalWorktree } from './background-terminal-worktree-mount'
+import {
+ addBackgroundMountedTerminalWorktree,
+ applyBackgroundMountTabRestriction,
+ hasRequestedBackgroundTerminalWorktreeMount,
+ pruneClosedBackgroundMountTabs,
+ requestBackgroundTerminalWorktreeMount,
+ subscribeBackgroundTerminalWorktreeMountRequests,
+ takeAllPendingBackgroundTerminalWorktreeMounts,
+ shouldMountBackgroundWorktreeTab
+} from './background-terminal-worktree-mount'
+
+describe('background terminal mount request registry', () => {
+ it('replays a request made before the Terminal listener mounts', () => {
+ takeAllPendingBackgroundTerminalWorktreeMounts()
+ const onRequest = vi.fn()
+ const unsubscribe = subscribeBackgroundTerminalWorktreeMountRequests(onRequest)
+
+ requestBackgroundTerminalWorktreeMount({ worktreeId: 'wt-cold', tabIds: ['tab-1'] })
+
+ expect(hasRequestedBackgroundTerminalWorktreeMount()).toBe(true)
+ expect(onRequest).toHaveBeenCalledOnce()
+ expect(takeAllPendingBackgroundTerminalWorktreeMounts()).toEqual([
+ { worktreeId: 'wt-cold', tabIds: ['tab-1'] }
+ ])
+ onRequest.mockClear()
+ requestBackgroundTerminalWorktreeMount({ worktreeId: 'wt-later', tabIds: ['tab-2'] })
+ expect(onRequest).not.toHaveBeenCalled()
+ unsubscribe()
+ })
+
+ it('unions targeted requests and lets a whole-worktree request dominate', () => {
+ takeAllPendingBackgroundTerminalWorktreeMounts()
+ requestBackgroundTerminalWorktreeMount({ worktreeId: 'wt-1', tabIds: ['tab-1'] })
+ requestBackgroundTerminalWorktreeMount({ worktreeId: 'wt-1', tabIds: ['tab-2'] })
+ expect(takeAllPendingBackgroundTerminalWorktreeMounts()).toEqual([
+ { worktreeId: 'wt-1', tabIds: ['tab-1', 'tab-2'] }
+ ])
+
+ requestBackgroundTerminalWorktreeMount({ worktreeId: 'wt-1', tabIds: ['tab-1'] })
+ requestBackgroundTerminalWorktreeMount({ worktreeId: 'wt-1' })
+ expect(takeAllPendingBackgroundTerminalWorktreeMounts()).toEqual([{ worktreeId: 'wt-1' }])
+ })
+})
describe('addBackgroundMountedTerminalWorktree', () => {
it('adds a hidden worktree mount and notifies the caller once', () => {
@@ -24,3 +66,79 @@ describe('addBackgroundMountedTerminalWorktree', () => {
expect(onAdded).not.toHaveBeenCalled()
})
})
+
+describe('applyBackgroundMountTabRestriction', () => {
+ it('restricts a not-yet-mounted worktree to the targeted tabs and unions later targets', () => {
+ const restrictions = new Map>()
+ const mounted = new Set()
+
+ applyBackgroundMountTabRestriction(restrictions, mounted, 'wt-1', ['tab-1'])
+ expect(restrictions.get('wt-1')).toEqual(new Set(['tab-1']))
+
+ mounted.add('wt-1')
+ applyBackgroundMountTabRestriction(restrictions, mounted, 'wt-1', ['tab-2', 'tab-1'])
+ expect(restrictions.get('wt-1')).toEqual(new Set(['tab-1', 'tab-2']))
+ })
+
+ it('never narrows a fully mounted worktree retroactively', () => {
+ const restrictions = new Map>()
+ const mounted = new Set(['wt-visited'])
+
+ applyBackgroundMountTabRestriction(restrictions, mounted, 'wt-visited', ['tab-1'])
+ expect(restrictions.has('wt-visited')).toBe(false)
+ })
+
+ it('lifts the restriction on an untargeted (whole-worktree) mount', () => {
+ const restrictions = new Map>([['wt-1', new Set(['tab-1'])]])
+ const mounted = new Set(['wt-1'])
+
+ applyBackgroundMountTabRestriction(restrictions, mounted, 'wt-1', undefined)
+ expect(restrictions.has('wt-1')).toBe(false)
+ })
+
+ it('keeps the existing set identity when the targets are already covered', () => {
+ const existing = new Set(['tab-1', 'tab-2'])
+ const restrictions = new Map>([['wt-1', existing]])
+ const mounted = new Set(['wt-1'])
+
+ applyBackgroundMountTabRestriction(restrictions, mounted, 'wt-1', ['tab-2'])
+ expect(restrictions.get('wt-1')).toBe(existing)
+ })
+})
+
+describe('shouldMountBackgroundWorktreeTab', () => {
+ it('mounts every tab without a restriction and only listed tabs with one', () => {
+ expect(shouldMountBackgroundWorktreeTab(null, 'tab-1')).toBe(true)
+ expect(shouldMountBackgroundWorktreeTab(new Set(['tab-1']), 'tab-1')).toBe(true)
+ expect(shouldMountBackgroundWorktreeTab(new Set(['tab-1']), 'tab-2')).toBe(false)
+ })
+})
+
+describe('pruneClosedBackgroundMountTabs', () => {
+ it('retains live targets and releases the mount after the final target closes', () => {
+ const restrictions = new Map>([
+ ['wt-1', new Set(['tab-1', 'tab-2'])]
+ ])
+ const mounted = new Set(['wt-1'])
+
+ expect(
+ pruneClosedBackgroundMountTabs(restrictions, mounted, {
+ 'wt-1': [{ id: 'tab-2' }, { id: 'unrelated-tab' }]
+ })
+ ).toBe(true)
+ expect(restrictions.get('wt-1')).toEqual(new Set(['tab-2']))
+ expect(mounted.has('wt-1')).toBe(true)
+
+ expect(pruneClosedBackgroundMountTabs(restrictions, mounted, { 'wt-1': [] })).toBe(true)
+ expect(restrictions.has('wt-1')).toBe(false)
+ expect(mounted.has('wt-1')).toBe(false)
+ })
+
+ it('leaves unrestricted user-visited and whole-worktree mounts alone', () => {
+ const restrictions = new Map>()
+ const mounted = new Set(['wt-visited', 'wt-whole'])
+
+ expect(pruneClosedBackgroundMountTabs(restrictions, mounted, {})).toBe(false)
+ expect(mounted).toEqual(new Set(['wt-visited', 'wt-whole']))
+ })
+})
diff --git a/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts b/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts
index 3aff5faf530..af8bc370d8e 100644
--- a/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts
+++ b/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts
@@ -1,3 +1,85 @@
+import {
+ BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT,
+ type BackgroundMountTerminalWorktreeDetail
+} from '@/constants/terminal'
+
+const pendingMounts = new Map()
+const requestListeners = new Set<() => void>()
+let hasRequestedMount = false
+
+function mergePendingMount(detail: BackgroundMountTerminalWorktreeDetail): void {
+ const existing = pendingMounts.get(detail.worktreeId)
+ if (!existing) {
+ pendingMounts.set(detail.worktreeId, {
+ worktreeId: detail.worktreeId,
+ ...(detail.tabIds !== undefined ? { tabIds: [...new Set(detail.tabIds)] } : {})
+ })
+ return
+ }
+ if (existing.tabIds === undefined || detail.tabIds === undefined) {
+ pendingMounts.set(detail.worktreeId, { worktreeId: detail.worktreeId })
+ return
+ }
+ pendingMounts.set(detail.worktreeId, {
+ worktreeId: detail.worktreeId,
+ tabIds: [...new Set([...existing.tabIds, ...detail.tabIds])]
+ })
+}
+
+/**
+ * Records the request before emitting the compatibility event. Why: Terminal
+ * is lazy and absent on the landing screen, so a window-only event can fire
+ * before its listener exists and permanently lose a navigation-free wake.
+ */
+export function requestBackgroundTerminalWorktreeMount(
+ detail: BackgroundMountTerminalWorktreeDetail
+): void {
+ if (!detail.worktreeId) {
+ return
+ }
+ mergePendingMount(detail)
+ if (!hasRequestedMount) {
+ hasRequestedMount = true
+ for (const listener of requestListeners) {
+ listener()
+ }
+ }
+ if (typeof window !== 'undefined') {
+ window.dispatchEvent(
+ new CustomEvent(
+ BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT,
+ { detail }
+ )
+ )
+ }
+}
+
+export function takePendingBackgroundTerminalWorktreeMount(
+ worktreeId: string | undefined
+): BackgroundMountTerminalWorktreeDetail | null {
+ if (!worktreeId) {
+ return null
+ }
+ const pending = pendingMounts.get(worktreeId) ?? null
+ pendingMounts.delete(worktreeId)
+ return pending
+}
+
+export function takeAllPendingBackgroundTerminalWorktreeMounts(): BackgroundMountTerminalWorktreeDetail[] {
+ const pending = [...pendingMounts.values()]
+ pendingMounts.clear()
+ return pending
+}
+
+export function subscribeBackgroundTerminalWorktreeMountRequests(listener: () => void): () => void {
+ requestListeners.add(listener)
+ return () => requestListeners.delete(listener)
+}
+
+export function hasRequestedBackgroundTerminalWorktreeMount(): boolean {
+ return hasRequestedMount
+}
+
export function addBackgroundMountedTerminalWorktree(
mountedWorktreeIds: Set,
worktreeId: string | undefined,
@@ -10,3 +92,69 @@ export function addBackgroundMountedTerminalWorktree(
onAdded()
return true
}
+
+/**
+ * Records which terminal tabs a background mount may instantiate. Why: a
+ * whole-worktree background mount creates a TerminalPane (xterm + PTY connect)
+ * for every saved tab, so wake/resume flows pass the exact tabs they need.
+ * Must run before the worktree is added to `mountedWorktreeIds`.
+ */
+export function applyBackgroundMountTabRestriction(
+ restrictions: Map>,
+ mountedWorktreeIds: ReadonlySet,
+ worktreeId: string | undefined,
+ tabIds: readonly string[] | undefined
+): void {
+ if (!worktreeId) {
+ return
+ }
+ const existing = restrictions.get(worktreeId)
+ // Why: a worktree mounted without a restriction is fully mounted (the user
+ // visited it, or a legacy whole-worktree mount ran); narrowing it
+ // retroactively would unmount live panes.
+ if (mountedWorktreeIds.has(worktreeId) && !existing) {
+ return
+ }
+ if (!tabIds) {
+ restrictions.delete(worktreeId)
+ return
+ }
+ if (existing && tabIds.every((tabId) => existing.has(tabId))) {
+ return
+ }
+ restrictions.set(worktreeId, new Set([...(existing ?? []), ...tabIds]))
+}
+
+export function shouldMountBackgroundWorktreeTab(
+ restrictedTabIds: ReadonlySet | null,
+ tabId: string
+): boolean {
+ return restrictedTabIds === null || restrictedTabIds.has(tabId)
+}
+
+/**
+ * Releases targeted mounts after their owning tabs close. Whole-worktree and
+ * user-visited mounts have no restriction entry and are intentionally retained.
+ */
+export function pruneClosedBackgroundMountTabs(
+ restrictions: Map>,
+ mountedWorktreeIds: Set,
+ tabsByWorktree: Record