('input')
+ if (!input) {
+ throw new Error('worktree path input not found')
+ }
+ return input
+}
+
+function setNativeValue(input: HTMLInputElement, text: string): void {
+ // Why: React reads controlled-input changes via the native value setter;
+ // assigning input.value directly is swallowed by React's value tracking.
+ const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ setValue?.call(input, text)
+}
+
+function typeText(input: HTMLInputElement, text: string): void {
+ act(() => {
+ setNativeValue(input, text)
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+}
+
+function blurInput(input: HTMLInputElement): void {
+ // Why: React delegates onBlur via focusout (which bubbles) not blur (which
+ // doesn't), so dispatching focusout is required to trigger the React handler.
+ act(() => {
+ input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
+ })
+}
+
+describe('RepositoryWorktreeDefaultsSection — worktree path', () => {
+ it('does not call updateRepo while the user is typing', () => {
+ const updateRepo = vi.fn()
+ render(BASE_REPO, updateRepo)
+
+ const input = getWorktreePathInput()
+ typeText(input, './w')
+ typeText(input, './wo')
+ typeText(input, './wor')
+ typeText(input, './worktree')
+
+ expect(updateRepo).not.toHaveBeenCalled()
+ })
+
+ it('calls updateRepo with the final value on blur', () => {
+ const updateRepo = vi.fn()
+ render(BASE_REPO, updateRepo)
+
+ const input = getWorktreePathInput()
+ typeText(input, ' ./worktree ')
+ blurInput(input)
+
+ expect(updateRepo).toHaveBeenCalledTimes(1)
+ expect(updateRepo).toHaveBeenCalledWith('repo-1', { worktreeBasePath: './worktree' })
+ })
+
+ it('does not call updateRepo when the normalized value is unchanged on blur', () => {
+ const updateRepo = vi.fn()
+ render({ ...BASE_REPO, worktreeBasePath: './worktree' }, updateRepo)
+
+ const input = getWorktreePathInput()
+ typeText(input, ' ./worktree ')
+ blurInput(input)
+
+ expect(updateRepo).not.toHaveBeenCalled()
+ })
+
+ it('calls updateRepo with undefined when the field is cleared', () => {
+ const updateRepo = vi.fn()
+ render({ ...BASE_REPO, worktreeBasePath: '../worktrees' }, updateRepo)
+
+ const input = getWorktreePathInput()
+ typeText(input, '')
+ blurInput(input)
+
+ expect(updateRepo).toHaveBeenCalledWith('repo-1', { worktreeBasePath: undefined })
+ })
+
+ it('calls updateRepo with undefined when the value is whitespace-only', () => {
+ const updateRepo = vi.fn()
+ render({ ...BASE_REPO, worktreeBasePath: '../worktrees' }, updateRepo)
+
+ const input = getWorktreePathInput()
+ typeText(input, ' ')
+ blurInput(input)
+
+ expect(updateRepo).toHaveBeenCalledWith('repo-1', { worktreeBasePath: undefined })
+ })
+})
diff --git a/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx b/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx
index ab13d3cfb7a..db262bd0645 100644
--- a/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx
+++ b/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx
@@ -83,9 +83,16 @@ export function RepositoryWorktreeDefaultsSection({
repoId={repo.id}
storeValue={repo.worktreeBasePath ?? ''}
placeholder={settings?.workspaceDir ?? ''}
- onTextChange={(text) =>
- updateRepo(repo.id, { worktreeBasePath: text.trim() ? text : undefined })
- }
+ onTextChange={() => {}}
+ onBlur={(e) => {
+ const worktreeBasePath = e.currentTarget.value.trim() || undefined
+ // Why: even an unchanged worktreeBasePath update asks main to
+ // prepare the root, which can touch the filesystem.
+ if (worktreeBasePath === (repo.worktreeBasePath?.trim() || undefined)) {
+ return
+ }
+ updateRepo(repo.id, { worktreeBasePath })
+ }}
className="h-9 text-sm"
/>
diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx
index c303866f52e..34db3fe66a7 100644
--- a/src/renderer/src/components/sidebar/WorktreeList.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeList.tsx
@@ -29,6 +29,7 @@ import type { AppState } from '@/store/types'
import {
getAllWorktreesFromState,
useAllWorktrees,
+ useProjectHostSetupProjection,
useRepoMap,
useWorktreeMap
} from '@/store/selectors'
@@ -5332,11 +5333,13 @@ const WorktreeList = React.memo(function WorktreeList({
// Why: manual repo header order is bound to state.repos. Recent/Smart derive
// header order from the sorted visible worktree stream instead.
const repos = useAppStore((s) => s.repos)
- const projects = useAppStore((s) => s.projects)
- const projectHostSetups = useAppStore((s) => s.projectHostSetups)
+ const projectHostSetupProjection = useProjectHostSetupProjection()
const projectGrouping = useMemo(
- () => ({ projects, projectHostSetups }),
- [projectHostSetups, projects]
+ () => ({
+ projects: projectHostSetupProjection.projects,
+ projectHostSetups: projectHostSetupProjection.setups
+ }),
+ [projectHostSetupProjection]
)
const projectGroups = useAppStore((s) => s.projectGroups ?? EMPTY_PROJECT_GROUPS)
const folderWorkspaces = useAppStore((s) => s.folderWorkspaces)
diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts
index 8bf89d66a73..e389023e540 100644
--- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
+import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection'
import {
ALL_GROUP_META,
buildRows,
@@ -361,6 +362,105 @@ describe('buildRows with pinned worktrees', () => {
])
})
+ it('renders same-project records with git remote identity as one mixed-host project header', () => {
+ const localRepo: Repo = {
+ ...repo,
+ id: 'local-sample-app',
+ path: '/Users/alice/work/sample-app',
+ displayName: 'sample-app',
+ gitRemoteIdentity: {
+ canonicalKey: 'git.company.test/team/sample-app',
+ remoteName: 'origin',
+ remoteUrl: 'git@git.company.test:team/sample-app.git'
+ }
+ }
+ const sshRepo: Repo = {
+ ...repo,
+ id: 'ssh-sample-app',
+ path: '/home/alice/src/sample-app',
+ displayName: 'sample-app',
+ connectionId: 'build server',
+ gitRemoteIdentity: {
+ canonicalKey: 'git.company.test/team/sample-app',
+ remoteName: 'origin',
+ remoteUrl: 'https://git.company.test/team/sample-app.git'
+ }
+ }
+ const runtimeRepo: Repo = {
+ ...repo,
+ id: 'runtime-sample-app',
+ path: '/workspace/sample-app',
+ displayName: 'sample-app',
+ executionHostId: 'runtime:dev-container',
+ gitRemoteIdentity: {
+ canonicalKey: 'git.company.test/team/sample-app',
+ remoteName: 'origin',
+ remoteUrl: 'ssh://git@git.company.test/team/sample-app.git'
+ }
+ }
+ const localWorktree: Worktree = {
+ ...worktree,
+ id: 'wt-local-sample-app',
+ repoId: localRepo.id,
+ path: '/Users/alice/work/sample-app-feature'
+ }
+ const sshWorktree: Worktree = {
+ ...worktree,
+ id: 'wt-ssh-sample-app',
+ repoId: sshRepo.id,
+ path: '/home/alice/src/sample-app-feature'
+ }
+ const runtimeWorktree: Worktree = {
+ ...worktree,
+ id: 'wt-runtime-sample-app',
+ repoId: runtimeRepo.id,
+ path: '/workspace/sample-app-feature'
+ }
+ const projection = projectHostSetupProjectionFromRepos([localRepo, sshRepo, runtimeRepo])
+ const rows = buildRows(
+ 'repo',
+ [localWorktree, sshWorktree, runtimeWorktree],
+ new Map([
+ [localRepo.id, localRepo],
+ [sshRepo.id, sshRepo],
+ [runtimeRepo.id, runtimeRepo]
+ ]),
+ null,
+ new Set(),
+ undefined,
+ undefined,
+ undefined,
+ {},
+ new Map([
+ [localWorktree.id, localWorktree],
+ [sshWorktree.id, sshWorktree],
+ [runtimeWorktree.id, runtimeWorktree]
+ ]),
+ false,
+ undefined,
+ [],
+ new Set(),
+ new Map(),
+ [],
+ {
+ projects: projection.projects,
+ projectHostSetups: projection.setups
+ }
+ )
+
+ expect(rows).toMatchObject([
+ {
+ type: 'header',
+ key: 'project:git:git.company.test/team/sample-app',
+ label: 'sample-app',
+ count: 3
+ },
+ { type: 'item', worktree: { id: localWorktree.id }, hostContextLabel: LOCAL_HOST_LABEL },
+ { type: 'item', worktree: { id: sshWorktree.id }, hostContextLabel: 'build server' },
+ { type: 'item', worktree: { id: runtimeWorktree.id }, hostContextLabel: 'dev-container' }
+ ])
+ })
+
it('orders project identity headers by the manual repo order anchor', () => {
const analyticsProject: Project = {
...project,
@@ -2506,6 +2606,13 @@ describe('WorktreeList header styles', () => {
expect(source).toContain('headerKey: row.key')
expect(source).toContain('color={repoHeaderColor}')
})
+
+ it('adapts projected setup rows for sidebar project grouping', () => {
+ const source = readWorktreeListSource()
+
+ expect(source).toContain('const projectHostSetupProjection = useProjectHostSetupProjection()')
+ expect(source).toContain('projectHostSetups: projectHostSetupProjection.setups')
+ })
})
describe('buildRows pending creations', () => {
diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts
index 7ea67c0d99b..ebedf8bed89 100644
--- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts
+++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts
@@ -21,6 +21,11 @@ import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
import { useAppStore } from '@/store'
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
+import {
+ markTerminalFollowOutput,
+ markTerminalPinnedViewport,
+ syncTerminalScrollIntentFromViewport
+} from '@/lib/pane-manager/terminal-scroll-intent'
export function recordKeyboardCreatedTerminalPaneSplit(
createdPane: unknown,
@@ -314,9 +319,13 @@ export function useTerminalKeyboardShortcuts({
return
}
if (action.position === 'top') {
+ markTerminalPinnedViewport(pane.terminal)
pane.terminal.scrollToLine(0)
+ syncTerminalScrollIntentFromViewport(pane.terminal)
} else {
+ markTerminalFollowOutput(pane.terminal)
pane.terminal.scrollToBottom()
+ syncTerminalScrollIntentFromViewport(pane.terminal)
}
return
}
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
index da2705e5288..c0d45d84249 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
@@ -6255,6 +6255,12 @@ describe('connectPanePty', () => {
const pane = createPane(1)
pane.terminal.buffer.active.viewportY = 42
pane.terminal.buffer.active.baseY = 100
+ pane.terminal.write.mockImplementation((data: string, callback?: () => void) => {
+ if (data.includes('snapshot-state')) {
+ pane.terminal.buffer.active.viewportY = 0
+ }
+ callback?.()
+ })
const manager = createManager(1)
const deps = createDeps({
isVisibleRef: { current: false }
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts
index 4133374fa2d..7277541f562 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
@@ -58,8 +58,11 @@ import {
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
import { recordAgentHibernationPaneOutput } from '@/lib/agent-hibernation-output-activity'
import { isLocalNativeWindowsConpty } from '@/lib/pane-manager/windows-pty-compatibility'
-import { recordTerminalOutput, restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll'
-import type { ScrollState } from '@/lib/pane-manager/pane-manager-types'
+import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll'
+import {
+ captureTerminalWriteScrollIntent,
+ enforceTerminalWriteScrollIntent
+} from '@/lib/pane-manager/terminal-scroll-intent'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id'
import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
@@ -3114,40 +3117,13 @@ export function connectPanePty(
})
}
- function captureScrollStateForSnapshotReplay(): ScrollState | null {
- const buf = pane.terminal.buffer?.active
- if (!buf) {
- return null
- }
- const viewportY = buf.viewportY
- const baseY = buf.baseY
- if (!Number.isFinite(viewportY) || !Number.isFinite(baseY)) {
- return null
- }
- return {
- bufferType: buf.type,
- wasAtBottom: viewportY >= baseY,
- viewportY,
- baseY
- }
- }
-
- function restoreScrollStateAfterSnapshotReplay(state: ScrollState | null): void {
- if (!state || state.wasAtBottom) {
- return
- }
- // Why: hidden-backlog replay clears xterm after visibility scroll restore;
- // re-apply a scrolled-up viewport so recovery does not jump to bottom.
- restoreScrollStateAfterLayout(pane.terminal, state)
- }
-
function applyMainBufferSnapshot(snapshot: {
data: string
cols: number
rows: number
seq?: number
}): void {
- const scrollState = captureScrollStateForSnapshotReplay()
+ const scrollIntent = captureTerminalWriteScrollIntent(pane.terminal)
const colsBeforeReplay = pane.terminal.cols
const rowsBeforeReplay = pane.terminal.rows
const hasSnapshotDimensions =
@@ -3191,7 +3167,9 @@ export function connectPanePty(
}
scheduleReattachIdleAgentCursorReset()
}
- restoreScrollStateAfterSnapshotReplay(scrollState)
+ // Why: snapshot replay clears and rebuilds xterm state; re-apply the
+ // user's scroll intent once so hidden catch-up cannot repin the viewport.
+ enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent)
}
function requestHiddenOutputRestoreIfNeeded(opts?: { bypassScheduler?: boolean }): boolean {
diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts
index 87db96c3a22..5feee00f707 100644
--- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts
@@ -5,7 +5,7 @@ import {
flushTerminalOutput,
requestTerminalBacklogRecovery
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
-import { restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll'
+import { enforceTerminalCurrentScrollIntent } from '@/lib/pane-manager/terminal-scroll-intent'
import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers'
const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024
@@ -47,7 +47,7 @@ export function resumeTerminalVisibility({
// post-resume fit runs. Capture numeric viewport positions first; the
// restore path avoids content matching so duplicate agent log lines do
// not jump to the wrong history entry.
- const viewportPositions = captureViewportPositions(!wasVisible)
+ captureViewportPositions(!wasVisible)
withSuppressedScrollTracking(() => {
if (shouldUseLightTabResume) {
// Why: intra-worktree tab switches only toggle the overlay. Keeping
@@ -61,7 +61,7 @@ export function resumeTerminalVisibility({
} else {
resumeTerminalVisibilityHeavy(manager, isActive)
}
- restoreTerminalViewportPositions(manager, viewportPositions)
+ enforceTerminalViewportIntents(manager)
if (!shouldUseLightTabResume) {
// Why: this clear wipes the glyph atlas shared with other same-config
// terminals; the global reset rebuilds their render models too.
@@ -137,14 +137,8 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean):
}
}
-function restoreTerminalViewportPositions(
- manager: PaneManager,
- viewportPositions: Map
-): void {
+function enforceTerminalViewportIntents(manager: PaneManager): void {
for (const pane of manager.getPanes()) {
- const position = viewportPositions.get(pane.id)
- if (position) {
- restoreScrollStateAfterLayout(pane.terminal, position)
- }
+ enforceTerminalCurrentScrollIntent(pane.terminal)
}
}
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
index df9f4ba45fc..75245c1dddd 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({
flushTerminalOutput: vi.fn(),
getTerminalOutputEpoch: vi.fn(() => 0),
handleTerminalFileDrop: vi.fn(),
+ enforceTerminalCurrentScrollIntent: vi.fn(),
pasteTerminalText: vi.fn(),
recordTerminalUserInputForLeaf: vi.fn(),
requestTerminalBacklogRecovery: vi.fn(),
@@ -75,6 +76,10 @@ vi.mock('@/lib/pane-manager/pane-scroll', () => ({
restoreScrollStateAfterLayout: mocks.restoreScrollStateAfterLayout
}))
+vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({
+ enforceTerminalCurrentScrollIntent: mocks.enforceTerminalCurrentScrollIntent
+}))
+
vi.mock('./terminal-drop-handler', () => ({
handleTerminalFileDrop: mocks.handleTerminalFileDrop
}))
@@ -234,6 +239,9 @@ describe('useTerminalPaneGlobalEffects', () => {
mocks.restoreScrollStateAfterLayout.mockImplementation((terminal: { name: string }) => {
order.push(`restore:${terminal.name}`)
})
+ mocks.enforceTerminalCurrentScrollIntent.mockImplementation((terminal: { name: string }) => {
+ order.push(`intent:${terminal.name}`)
+ })
mocks.fitAndFocusPanes.mockImplementation(() => order.push('fit-focus'))
// Why: the resume path resets atlases through the live-manager registry
@@ -267,10 +275,11 @@ describe('useTerminalPaneGlobalEffects', () => {
'flush:terminal-b',
'resume',
'fit-focus',
- 'restore:terminal-a',
- 'restore:terminal-b',
+ 'intent:terminal-a',
+ 'intent:terminal-b',
'reset-atlas'
])
+ expect(mocks.restoreScrollStateAfterLayout).not.toHaveBeenCalled()
expect(mocks.flushTerminalOutput).toHaveBeenNthCalledWith(1, terminalA, {
maxChars: 256 * 1024
})
@@ -563,7 +572,7 @@ describe('useTerminalPaneGlobalEffects', () => {
expect(window.api.pty.setActiveRendererPty).toHaveBeenCalledWith('pty-active', true)
})
- it('restores from the pre-hide scroll state when hidden layout changes the viewport', () => {
+ it('enforces scroll intent after hidden layout changes the viewport', () => {
const terminalA = { name: 'terminal-a' }
const manager = {
getPanes: vi.fn(() => [{ id: 1, terminal: terminalA }]),
@@ -618,7 +627,8 @@ describe('useTerminalPaneGlobalEffects', () => {
expect(mocks.captureScrollState).toHaveBeenCalledTimes(2)
expect(manager.suspendRendering).toHaveBeenCalledTimes(1)
- expect(mocks.restoreScrollStateAfterLayout).toHaveBeenLastCalledWith(terminalA, preHideState)
+ expect(mocks.restoreScrollStateAfterLayout).not.toHaveBeenCalled()
+ expect(mocks.enforceTerminalCurrentScrollIntent).toHaveBeenLastCalledWith(terminalA)
})
it('clears WebGL texture atlases when the active visible terminal regains focus', () => {
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
index cfd5b6f398a..f26d9adc198 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
@@ -71,6 +71,10 @@ import { getConnectionId } from '@/lib/connection-context'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard'
import { fitAndFocusPanes, fitPanes } from './pane-helpers'
+import {
+ markTerminalPinnedViewport,
+ syncTerminalScrollIntentSoon
+} from '@/lib/pane-manager/terminal-scroll-intent'
import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { e2eConfig } from '@/lib/e2e-config'
import {
@@ -720,6 +724,15 @@ export function useTerminalPaneLifecycle({
return false
}
+ if (e.type === 'keydown') {
+ if (e.key === 'PageUp' || e.key === 'Home') {
+ markTerminalPinnedViewport(pane.terminal)
+ syncTerminalScrollIntentSoon(pane.terminal, { preservePinnedAtBottom: true })
+ } else if (e.key === 'PageDown' || e.key === 'End') {
+ syncTerminalScrollIntentSoon(pane.terminal)
+ }
+ }
+
return !shouldBypassXtermKeyboardEvent(e, {
isMac,
hasSelection: pane.terminal.hasSelection()
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts
index c30d96a5387..3cd22968f4b 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts
@@ -11,7 +11,9 @@ const mocks = vi.hoisted(() => ({
baseY: 0
})),
flushTerminalOutput: vi.fn(),
- getTerminalOutputEpoch: vi.fn(() => 1)
+ getTerminalOutputEpoch: vi.fn(() => 1),
+ getTerminalScrollIntentKind: vi.fn(() => 'followOutput'),
+ markTerminalFollowOutput: vi.fn()
}))
const reactRefState = vi.hoisted(() => ({
@@ -68,6 +70,11 @@ vi.mock('@/lib/pane-manager/pane-scroll', () => ({
getTerminalOutputEpoch: mocks.getTerminalOutputEpoch
}))
+vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({
+ getTerminalScrollIntentKind: mocks.getTerminalScrollIntentKind,
+ markTerminalFollowOutput: mocks.markTerminalFollowOutput
+}))
+
describe('useTerminalScrollVisibilityMemory', () => {
const originalRequestAnimationFrame = globalThis.requestAnimationFrame
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame
@@ -75,6 +82,7 @@ describe('useTerminalScrollVisibilityMemory', () => {
beforeEach(() => {
resetHookRefs()
vi.clearAllMocks()
+ mocks.getTerminalScrollIntentKind.mockReturnValue('followOutput')
})
afterEach(() => {
@@ -121,6 +129,42 @@ describe('useTerminalScrollVisibilityMemory', () => {
maxChars: 256 * 1024
})
expect(terminal.scrollToBottom).toHaveBeenCalled()
+ expect(mocks.markTerminalFollowOutput).toHaveBeenCalledWith(terminal)
+ })
+
+ it('does not turn a pinned viewport into follow-output when pending focus requests catch up', () => {
+ mocks.getTerminalScrollIntentKind.mockReturnValue('pinnedViewport')
+ const terminal = {
+ onScroll: vi.fn(() => ({ dispose: vi.fn() })),
+ scrollToBottom: vi.fn()
+ }
+ const manager = {
+ getPanes: vi.fn(() => [{ id: 1, terminal }])
+ }
+ const animationFrames: FrameRequestCallback[] = []
+ globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
+ animationFrames.push(callback)
+ return animationFrames.length
+ })
+
+ beginHookRender()
+ const visibilityMemory = useTerminalScrollVisibilityMemory({
+ managerRef: { current: manager as never },
+ isVisibleRef: { current: true },
+ visibleResumeCompleteRef: { current: true },
+ paneCount: 1
+ })
+
+ visibilityMemory.scheduleFollowOutputIfNeeded(1)
+ animationFrames.shift()?.(16)
+ animationFrames.shift()?.(32)
+
+ expect(mocks.flushTerminalOutput).toHaveBeenCalledWith(terminal, {
+ maxChars: 256 * 1024
+ })
+ expect(mocks.cancelDeferredScrollRestore).not.toHaveBeenCalled()
+ expect(mocks.markTerminalFollowOutput).not.toHaveBeenCalled()
+ expect(terminal.scrollToBottom).not.toHaveBeenCalled()
})
it('cancels pending follow-output frames on cleanup', () => {
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts
index 182ef2741e9..05afb66d5d4 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts
@@ -1,11 +1,15 @@
import { useCallback, useEffect, useRef } from 'react'
-import type { IDisposable, Terminal } from '@xterm/xterm'
+import type { Terminal } from '@xterm/xterm'
import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
import {
cancelDeferredScrollRestore,
captureScrollState,
getTerminalOutputEpoch
} from '@/lib/pane-manager/pane-scroll'
+import {
+ getTerminalScrollIntentKind,
+ markTerminalFollowOutput
+} from '@/lib/pane-manager/terminal-scroll-intent'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { ScrollState } from '@/lib/pane-manager/pane-manager-types'
@@ -37,7 +41,6 @@ export function useTerminalScrollVisibilityMemory({
paneCount
}: UseTerminalScrollVisibilityMemoryArgs): TerminalScrollVisibilityMemory {
const visibleScrollSnapshotsRef = useRef