+
+
+ {translate(
+ 'auto.components.mobile.MobileHero.pairingQrError',
+ 'This pairing code couldn’t be rendered as a QR code. Copy it into Orca Mobile instead.'
+ )}
+
+
- {translate(
- 'auto.components.settings.MobilePane.310924ad2c',
- 'Scan this code with the Orca mobile app. Each code creates a unique device token.'
- )}
-
+ {qrError ? (
+
+
+
+ {translate(
+ 'auto.components.settings.MobilePane.pairingQrError',
+ 'This pairing code couldn’t be rendered as a QR code. Copy it into Orca Mobile instead.'
+ )}
+
+
+ ) : (
+
+ {translate(
+ 'auto.components.settings.MobilePane.310924ad2c',
+ 'Scan this code with the Orca mobile app. Each code creates a unique device token.'
+ )}
+
+ )}
{pairingUrl && (
@@ -116,30 +132,35 @@ export function MobilePairingQrSection({
)}
)
}))
vi.mock('./MobilePairedDevicesSection', () => ({
@@ -224,6 +232,25 @@ describe('MobilePane pairing connection mode', () => {
expect(screen.queryByTestId('relay-degraded-notice')).not.toBeInTheDocument()
})
+ it('keeps the copy fallback when QR encoding fails', async () => {
+ getPairingQR.mockResolvedValue({
+ available: true,
+ qrDataUrl: null,
+ qrError: 'encoding_failed',
+ pairingUrl: 'orca://pair?code=copy-fallback',
+ endpoint: 'wss://host.example/large',
+ connectionMode: 'automatic'
+ })
+ const user = userEvent.setup()
+ render()
+
+ await user.click(screen.getByRole('button', { name: 'Generate' }))
+
+ await waitFor(() => expect(screen.getByTestId('qr-error')).toHaveTextContent('true'))
+ expect(screen.getByTestId('qr')).toHaveTextContent('none')
+ expect(screen.getByTestId('pairing-url')).toHaveTextContent('copy-fallback')
+ })
+
it('persists the chosen path when the mode changes', async () => {
const user = userEvent.setup()
render()
diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx
index aa2a9aa9472..56e9350c037 100644
--- a/src/renderer/src/components/settings/MobilePane.tsx
+++ b/src/renderer/src/components/settings/MobilePane.tsx
@@ -32,6 +32,7 @@ export function MobilePane(): React.JSX.Element {
const updateSettings = useAppStore((s) => s.updateSettings)
const [qrDataUrl, setQrDataUrl] = useState(null)
const [pairingUrl, setPairingUrl] = useState(null)
+ const [qrError, setQrError] = useState(false)
// Mode the displayed QR actually encodes; can be 'local-only' under an
// Anywhere selection when Relay provisioning degraded server-side.
const [qrEncodedMode, setQrEncodedMode] = useState(null)
@@ -85,6 +86,7 @@ export function MobilePane(): React.JSX.Element {
const hadPending = qrDisplayedRef.current || loadingRef.current
setQrDataUrl(null)
setPairingUrl(null)
+ setQrError(false)
setQrEncodedMode(null)
setEndpoint(null)
// Why: a superseded in-flight generate no longer clears loading in its
@@ -173,6 +175,7 @@ export function MobilePane(): React.JSX.Element {
}
const requestId = ++pairingRequestIdRef.current
setLoading(true)
+ setQrError(false)
try {
const result = await window.api.mobile.getPairingQR({
...(selectedAddress ? { address: selectedAddress } : {}),
@@ -192,6 +195,7 @@ export function MobilePane(): React.JSX.Element {
if (mountedRef.current) {
setQrDataUrl(result.qrDataUrl)
setPairingUrl(result.pairingUrl)
+ setQrError(result.qrDataUrl === null)
setQrEncodedMode(result.connectionMode)
setEndpoint(result.endpoint)
setDeviceCountAtQr(getPairedMobileDevicesSnapshot().length)
@@ -359,6 +363,7 @@ export function MobilePane(): React.JSX.Element {
-
+ {
+ it('keeps renderer validation off the Zod schema import path', () => {
+ const source = readFileSync(resolve('src/shared/network/manual-address.ts'), 'utf8')
+ expect(source).not.toContain('mobile-relay-pairing-offer')
+ expect(source).not.toContain("from 'zod'")
+ })
+
describe('IPv4', () => {
it('accepts canonical IPv4', () => {
expect(parseManualNetworkAddress('192.168.1.24')).toEqual({
@@ -14,8 +23,8 @@ describe('parseManualNetworkAddress', () => {
})
})
- it('accepts boundary IPv4 values', () => {
- expect(parseManualNetworkAddress('0.0.0.0').ok).toBe(true)
+ it('accepts the upper boundary and rejects the wildcard address', () => {
+ expect(parseManualNetworkAddress('0.0.0.0').ok).toBe(false)
expect(parseManualNetworkAddress('255.255.255.255').ok).toBe(true)
})
@@ -30,7 +39,7 @@ describe('parseManualNetworkAddress', () => {
for (const bad of ['', ' ', '1.2.3', '1.2.3.4.5', '256.0.0.1']) {
expect(parseManualNetworkAddress(bad)).toEqual({
ok: false,
- error: 'Enter an IPv4 address or hostname, optionally with a :port suffix'
+ error: 'Enter an IPv4/IPv6 address, hostname, or HTTP(S)/WebSocket URL'
})
}
})
@@ -38,9 +47,8 @@ describe('parseManualNetworkAddress', () => {
it('rejects leading zeros in octets', () => {
expect(parseManualNetworkAddress('01.02.03.04')).toEqual({
ok: false,
- error: 'Enter an IPv4 address or hostname, optionally with a :port suffix'
+ error: 'Enter an IPv4/IPv6 address, hostname, or HTTP(S)/WebSocket URL'
})
- expect(parseManualNetworkAddress('0.0.0.0').ok).toBe(true)
})
})
@@ -161,13 +169,109 @@ describe('parseManualNetworkAddress', () => {
expect(parseManualNetworkAddress(`example.com:${'0'.repeat(1000)}8080`).ok).toBe(false)
})
- it('rejects addresses with more than one colon (e.g. IPv6-shaped input)', () => {
+ it('rejects malformed addresses with more than one colon', () => {
expect(parseManualNetworkAddress('example.com:80:90').ok).toBe(false)
- expect(parseManualNetworkAddress('::1').ok).toBe(false)
+ })
+ })
+
+ describe('IPv6', () => {
+ it('accepts bare and bracketed IPv6 addresses with optional ports', () => {
+ expect(parseManualNetworkAddress('::1')).toEqual({ ok: true, address: '::1' })
+ expect(parseManualNetworkAddress('2001:db8::4')).toEqual({
+ ok: true,
+ address: '2001:db8::4'
+ })
+ expect(parseManualNetworkAddress('0:0:0:0:0:0:0:1')).toEqual({
+ ok: true,
+ address: '0:0:0:0:0:0:0:1'
+ })
+ expect(parseManualNetworkAddress('[2001:db8::4]:7443')).toEqual({
+ ok: true,
+ address: '[2001:db8::4]:7443'
+ })
+ expect(parseManualNetworkAddress('[0:0:0:0:0:0:0:1]:7443')).toEqual({
+ ok: true,
+ address: '[0:0:0:0:0:0:0:1]:7443'
+ })
+ })
+
+ it('rejects wildcard, malformed, and invalid-port IPv6 addresses', () => {
+ for (const bad of [
+ '::',
+ '[::]:8080',
+ '0:0:0:0:0:0:0:0',
+ '[0:0:0:0:0:0:0:0]:8080',
+ '::ffff:0.0.0.0',
+ '[::ffff:0.0.0.0]:8080',
+ '[::1]:0',
+ '[::1]:65536',
+ '[::1]:'
+ ]) {
+ expect(parseManualNetworkAddress(bad).ok).toBe(false)
+ }
+ })
+
+ it('accepts reachable IPv4-mapped and native IPv6 controls', () => {
+ expect(parseManualNetworkAddress('::ffff:192.168.1.24').ok).toBe(true)
+ expect(parseManualNetworkAddress('[::ffff:192.168.1.24]:8080').ok).toBe(true)
+ expect(parseManualNetworkAddress('2001:db8::24').ok).toBe(true)
+ })
+ })
+
+ describe('full URL', () => {
+ it('accepts ws:// and wss:// URLs with optional ports', () => {
+ expect(parseManualNetworkAddress('wss://example.com')).toEqual({
+ ok: true,
+ address: 'wss://example.com'
+ })
+ expect(parseManualNetworkAddress('wss://example.com:443')).toEqual({
+ ok: true,
+ address: 'wss://example.com:443'
+ })
+ expect(parseManualNetworkAddress('ws://example.com:8080')).toEqual({
+ ok: true,
+ address: 'ws://example.com:8080'
+ })
+ })
+
+ it('accepts reverse-proxy paths and query strings', () => {
+ expect(parseManualNetworkAddress('wss://example.com/orca?route=runtime').ok).toBe(true)
+ })
+
+ it('accepts HTTP URLs that the main process normalizes to WebSocket URLs', () => {
+ expect(parseManualNetworkAddress('http://example.com/orca').ok).toBe(true)
+ expect(parseManualNetworkAddress('https://example.com/orca?route=runtime').ok).toBe(true)
+ })
+
+ it('rejects URLs the pairing endpoint cannot advertise', () => {
+ for (const bad of [
+ 'ftp://example.com',
+ 'wss://',
+ 'wss://user:secret@example.com',
+ 'wss://example.com/#fragment',
+ 'wss://example.com:0',
+ 'ws://0.0.0.0:8080',
+ 'ws://[::]:8080',
+ 'ws://[0:0:0:0:0:0:0:0]:8080',
+ 'ws://[::ffff:0.0.0.0]:8080'
+ ]) {
+ expect(parseManualNetworkAddress(bad).ok).toBe(false)
+ }
})
})
describe('length and whitespace', () => {
+ it('matches the shared pairing endpoint length boundary', () => {
+ const prefix = 'wss://example.com/'
+ const atLimit = `${prefix}${'a'.repeat(PAIRING_ENDPOINT_MAX_CHARACTERS - prefix.length)}`
+ const aboveLimit = `${atLimit}a`
+
+ expect(atLimit).toHaveLength(PAIRING_ENDPOINT_MAX_CHARACTERS)
+ expect(parseManualNetworkAddress(atLimit)).toEqual({ ok: true, address: atLimit })
+ expect(aboveLimit).toHaveLength(PAIRING_ENDPOINT_MAX_CHARACTERS + 1)
+ expect(parseManualNetworkAddress(aboveLimit).ok).toBe(false)
+ })
+
it('rejects inputs longer than 253 chars', () => {
const long = `${'a'.repeat(250)}.ts.net`
expect(long.length).toBeGreaterThan(253)
diff --git a/src/shared/network/manual-address.ts b/src/shared/network/manual-address.ts
index 81a8a28a26b..120886f7b32 100644
--- a/src/shared/network/manual-address.ts
+++ b/src/shared/network/manual-address.ts
@@ -1,6 +1,7 @@
-// Why: pure shared helper so the same validation runs in renderer
-// today and in any future CLI/main-process caller without duplicating
-// the IPv4 + hostname + optional-port grammar.
+import { PAIRING_ENDPOINT_MAX_CHARACTERS } from '../mobile-pairing-protocol-limits'
+import { isPairingWildcardHostname, normalizePairingUrl } from './pairing-url'
+
+// Why: pairing entry points must accept the same endpoint forms as the main process.
const IPV4_OCTET = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])'
const IPV4 = `(?:${IPV4_OCTET}\\.){3}${IPV4_OCTET}`
const IPV4_REGEX = new RegExp(`^${IPV4}$`)
@@ -16,18 +17,27 @@ const HOSTNAME_REGEX = new RegExp(`^${HOSTNAME}$`, 'i')
const HOSTNAME_MAX_LENGTH = 253
const MIN_PORT = 1
const MAX_PORT = 65535
-const ERROR_MESSAGE = 'Enter an IPv4 address or hostname, optionally with a :port suffix'
+const ERROR_MESSAGE = 'Enter an IPv4/IPv6 address, hostname, or HTTP(S)/WebSocket URL'
export type ParseManualAddressResult = { ok: true; address: string } | { ok: false; error: string }
export function parseManualNetworkAddress(input: string): ParseManualAddressResult {
const trimmed = input.trim()
- if (trimmed === '') {
+ if (trimmed === '' || trimmed.length > PAIRING_ENDPOINT_MAX_CHARACTERS) {
return { ok: false, error: ERROR_MESSAGE }
}
if (/\s/.test(trimmed)) {
return { ok: false, error: ERROR_MESSAGE }
}
+ if (trimmed.includes('://')) {
+ return normalizePairingUrl(trimmed)
+ ? { ok: true, address: trimmed }
+ : { ok: false, error: ERROR_MESSAGE }
+ }
+
+ if (isValidIpv6Override(trimmed)) {
+ return { ok: true, address: trimmed }
+ }
const { host, port } = splitHostPort(trimmed)
if (host === '' || host.length > HOSTNAME_MAX_LENGTH) {
@@ -37,7 +47,7 @@ export function parseManualNetworkAddress(input: string): ParseManualAddressResu
return { ok: false, error: ERROR_MESSAGE }
}
- if (IPV4_REGEX.test(host)) {
+ if (IPV4_REGEX.test(host) && host !== '0.0.0.0') {
return { ok: true, address: trimmed }
}
// Why: reject a host whose last label is numeric — a bare `123`, a dotted
@@ -58,11 +68,7 @@ export function parseManualNetworkAddress(input: string): ParseManualAddressResu
return { ok: false, error: ERROR_MESSAGE }
}
-// Why: mirrors `parsePairingAddressOverride` in src/main/runtime/runtime-rpc.ts
-// so the UI only accepts what the main process's pairing endpoint resolution
-// can already handle. IPv6 stays out of scope (same as that function), so a
-// second colon is left in `host` and fails the grammar checks below instead
-// of being misparsed as a port.
+// Why: only a single colon can separate a hostname and port; IPv6 is parsed first.
function splitHostPort(value: string): { host: string; port: string | null } {
const firstColon = value.indexOf(':')
if (firstColon === -1 || value.includes(':', firstColon + 1)) {
@@ -81,3 +87,18 @@ function isValidPort(port: string): boolean {
const value = Number(port)
return value >= MIN_PORT && value <= MAX_PORT
}
+
+function isValidIpv6Override(value: string): boolean {
+ const bracketed = value.match(/^\[([^\]]+)\](?::(\d+))?$/)
+ const hostname = bracketed?.[1] ?? value
+ const port = bracketed?.[2]
+ if ((!bracketed && !value.includes(':')) || hostname === '::' || (port && !isValidPort(port))) {
+ return false
+ }
+ try {
+ const url = new URL(`ws://[${hostname}]`)
+ return url.hostname.includes(':') && !isPairingWildcardHostname(url.hostname)
+ } catch {
+ return false
+ }
+}
diff --git a/src/shared/network/pairing-url.ts b/src/shared/network/pairing-url.ts
new file mode 100644
index 00000000000..c6d52acc8cd
--- /dev/null
+++ b/src/shared/network/pairing-url.ts
@@ -0,0 +1,50 @@
+import { PAIRING_ENDPOINT_MAX_CHARACTERS } from '../mobile-pairing-protocol-limits'
+
+export function normalizePairingUrl(value: string): string | null {
+ if (value.length > PAIRING_ENDPOINT_MAX_CHARACTERS || containsAsciiControlCharacter(value)) {
+ return null
+ }
+ try {
+ const endpoint = new URL(value)
+ if (endpoint.protocol === 'http:') {
+ endpoint.protocol = 'ws:'
+ } else if (endpoint.protocol === 'https:') {
+ endpoint.protocol = 'wss:'
+ }
+ if (
+ (endpoint.protocol !== 'ws:' && endpoint.protocol !== 'wss:') ||
+ !endpoint.hostname ||
+ endpoint.username ||
+ endpoint.password ||
+ endpoint.hash ||
+ endpoint.port === '0' ||
+ isPairingWildcardHostname(endpoint.hostname)
+ ) {
+ return null
+ }
+ const formatted = endpoint.toString()
+ return endpoint.pathname === '/' && !endpoint.search ? formatted.replace(/\/$/, '') : formatted
+ } catch {
+ return null
+ }
+}
+
+function containsAsciiControlCharacter(value: string): boolean {
+ for (const character of value) {
+ const code = character.charCodeAt(0)
+ if (code <= 31 || code === 127) {
+ return true
+ }
+ }
+ return false
+}
+
+export function isPairingWildcardHostname(hostname: string): boolean {
+ const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase()
+ return (
+ normalized === '*' ||
+ normalized === '0.0.0.0' ||
+ normalized === '::' ||
+ normalized === '::ffff:0:0'
+ )
+}
diff --git a/src/shared/pairing.ts b/src/shared/pairing.ts
index 4a4b6350fa6..00a3e7e0fdd 100644
--- a/src/shared/pairing.ts
+++ b/src/shared/pairing.ts
@@ -1,10 +1,12 @@
import {
- PAIRING_CODE_MAX_CHARACTERS,
- PAIRING_INPUT_MAX_CHARACTERS,
PAIRING_OFFER_VERSION,
PairingOfferSchema,
type PairingOffer
} from './mobile-relay-pairing-offer'
+import {
+ PAIRING_CODE_MAX_CHARACTERS,
+ PAIRING_INPUT_MAX_CHARACTERS
+} from './mobile-pairing-protocol-limits'
export { PAIRING_OFFER_VERSION, PairingOfferSchema }
export type { PairingOffer }
From afbd98d8a440af7bb3c9c3ce24a883316f8197c3 Mon Sep 17 00:00:00 2001
From: Michael <123170392+MichaelHoughtonDeBox@users.noreply.github.com>
Date: Wed, 29 Jul 2026 04:51:14 +0100
Subject: [PATCH 11/67] Support Windows drives in the remote host filesystem
picker (#7439)
* Support Windows drives in the remote host filesystem picker
The remote picker was locked to the system drive on Windows hosts: the
breadcrumb root resolved to C:\ and typed drive paths (M:\dev) were
treated as filter text, so projects could only ever be created on C:.
- Server: answer host-root browses ('/') on win32 with the mounted
drives instead of resolving to C:\.
- Client: recognize drive-anchored input (M:\, M:/, m:) as path mode,
resolve segments from the normalized drive root, and make
joinPath/parentPath/breadcrumbs drive-aware. Up from a drive root
returns to the host root (the drive list).
Fixes #7438
Co-Authored-By: Claude Fable 5
* Document why joinDrivePath uses a literal backslash
Review feedback suggested path.win32.join, but the renderer bundle
imports no Node builtins anywhere and runs sandboxed, so path.win32 is
not available here. The backslash targets the remote Windows host
regardless of client OS; say so at the call site.
Co-Authored-By: Claude Fable 5
* Complete Windows drive browsing over SSH
* fix remote Windows drive browsing
* fix(ui): key remote breadcrumbs by path
---------
Co-authored-by: Claude Fable 5
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
---
src/main/ipc/ssh-browse.test.ts | 51 ++++++++++-
src/main/ipc/ssh-browse.ts | 37 +++++---
src/main/runtime/orca-runtime.test.ts | 15 ++++
src/main/runtime/orca-runtime.ts | 18 +++-
.../runtime/windows-drive-listing.test.ts | 64 +++++++++++++
src/main/runtime/windows-drive-listing.ts | 57 ++++++++++++
src/preload/api-types.ts | 2 +
src/preload/index.ts | 2 +
.../sidebar/RemoteFileBrowser.paste.test.tsx | 58 ++++++++++--
.../components/sidebar/RemoteFileBrowser.tsx | 89 +++++++++++++------
.../remote-file-browser-drive-paths.test.ts | 86 ++++++++++++++++++
.../remote-file-browser-drive-paths.ts | 62 +++++++++++++
.../remote-file-browser-helpers.test.ts | 69 ++++++++++++++
.../sidebar/remote-file-browser-helpers.ts | 68 ++++++++++----
.../runtime-server-directory-browser.test.ts | 2 +
.../runtime-server-directory-browser.ts | 3 +-
src/renderer/src/web/web-preload-api.ts | 2 +-
src/shared/types.ts | 2 +
.../helpers/nested-runtime-same-id-pairing.ts | 2 +-
.../nested-runtime-ssh-client-route.ts | 2 +-
tests/e2e/helpers/paired-electron-client.ts | 8 +-
...untime-file-browser-windows-drives.spec.ts | 53 +++++++++++
22 files changed, 683 insertions(+), 69 deletions(-)
create mode 100644 src/main/runtime/windows-drive-listing.test.ts
create mode 100644 src/main/runtime/windows-drive-listing.ts
create mode 100644 src/renderer/src/components/sidebar/remote-file-browser-drive-paths.test.ts
create mode 100644 src/renderer/src/components/sidebar/remote-file-browser-drive-paths.ts
create mode 100644 tests/e2e/runtime-file-browser-windows-drives.spec.ts
diff --git a/src/main/ipc/ssh-browse.test.ts b/src/main/ipc/ssh-browse.test.ts
index 6d04e8c7b0f..716c35f30ab 100644
--- a/src/main/ipc/ssh-browse.test.ts
+++ b/src/main/ipc/ssh-browse.test.ts
@@ -62,6 +62,7 @@ describe('registerSshBrowseHandler', () => {
await expect(resultPromise).resolves.toEqual({
resolvedPath: '/home/user',
+ pathFlavor: 'posix',
entries: [
{ name: 'src', isDirectory: true },
{ name: 'notes file.txt', isDirectory: false },
@@ -93,6 +94,7 @@ describe('registerSshBrowseHandler', () => {
await expect(resultPromise).resolves.toEqual({
resolvedPath: "/tmp/it's here",
+ pathFlavor: 'posix',
entries: []
})
expect(exec).toHaveBeenCalledWith("cd '/tmp/it'\\''s here' && pwd && command ls -1Ap")
@@ -128,6 +130,7 @@ describe('registerSshBrowseHandler', () => {
await expect(resultPromise).resolves.toEqual({
resolvedPath: 'C:/Users/alice',
+ pathFlavor: 'win32',
entries: [
{ name: 'Desktop', isDirectory: true },
{ name: 'notes.txt', isDirectory: false }
@@ -150,6 +153,40 @@ describe('registerSshBrowseHandler', () => {
expect(script).toContain("Write-Output ($resolved -replace '\\\\', '/')")
})
+ it('lists Windows drive roots when an SSH picker browses the host root', async () => {
+ const posixChannel = createMockChannel()
+ const windowsChannel = createMockChannel()
+ const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
+ const getConnectionManager = () => ({
+ getConnection: () => ({ exec })
+ })
+ registerSshBrowseHandler(getConnectionManager as never)
+
+ const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '/' })
+ await Promise.resolve()
+ posixChannel.stderr.emit('data', Buffer.from('"exec" is not recognized'))
+ posixChannel.emit('exit', 1)
+ posixChannel.emit('close')
+ await vi.waitFor(() => {
+ expect(windowsChannel.listenerCount('close')).toBe(1)
+ })
+ windowsChannel.emit('data', Buffer.from('/\r\nC:\\/\r\nM:\\/\r\n'))
+ windowsChannel.emit('exit', 0)
+ windowsChannel.emit('close')
+
+ await expect(resultPromise).resolves.toEqual({
+ resolvedPath: '/',
+ pathFlavor: 'win32',
+ entries: [
+ { name: 'C:\\', isDirectory: true },
+ { name: 'M:\\', isDirectory: true }
+ ]
+ })
+ const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '')
+ expect(script).toContain('Get-PSDrive -PSProvider FileSystem')
+ expect(script).not.toContain('Set-Location')
+ })
+
it('falls back for a non-English cmd.exe reject (exit 1, localized stderr)', async () => {
// Regression: real Windows OpenSSH + cmd.exe forwards exit 1 (not 9009) with
// localized stderr. The old 9009/English-string trigger silently missed this,
@@ -182,6 +219,7 @@ describe('registerSshBrowseHandler', () => {
await expect(resultPromise).resolves.toEqual({
resolvedPath: 'C:/Users',
+ pathFlavor: 'win32',
entries: [{ name: 'Admin', isDirectory: true }]
})
expect(exec).toHaveBeenCalledTimes(2)
@@ -213,6 +251,7 @@ describe('registerSshBrowseHandler', () => {
await expect(resultPromise).resolves.toEqual({
resolvedPath: "C:/O'Brien",
+ pathFlavor: 'win32',
entries: []
})
const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '')
@@ -241,7 +280,11 @@ describe('registerSshBrowseHandler', () => {
windowsChannel.emit('exit', 0)
windowsChannel.emit('close')
- await expect(resultPromise).resolves.toEqual({ resolvedPath: 'C:/Users/alice', entries: [] })
+ await expect(resultPromise).resolves.toEqual({
+ resolvedPath: 'C:/Users/alice',
+ entries: [],
+ pathFlavor: 'win32'
+ })
const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '')
// ~ must expand to $HOME, not be passed literally to Set-Location.
expect(script).toContain('$dir = $HOME')
@@ -279,7 +322,11 @@ describe('registerSshBrowseHandler', () => {
windowsChannel.emit('exit', 0)
windowsChannel.emit('close')
- await expect(resultPromise).resolves.toEqual({ resolvedPath: 'C:/Users', entries: [] })
+ await expect(resultPromise).resolves.toEqual({
+ resolvedPath: 'C:/Users',
+ entries: [],
+ pathFlavor: 'win32'
+ })
const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '')
expect(script).toContain(expected)
}
diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts
index f42c64a803d..2356de614b0 100644
--- a/src/main/ipc/ssh-browse.ts
+++ b/src/main/ipc/ssh-browse.ts
@@ -2,12 +2,19 @@ import { ipcMain } from 'electron'
import type { SshConnectionManager } from '../ssh/ssh-connection-manager'
import type { SshExecOptions } from '../ssh/ssh-connection-utils'
import { powerShellCommand, powerShellLiteral } from '../ssh/ssh-remote-powershell'
+import type { FilesystemPathFlavor } from '../../shared/types'
export type RemoteDirEntry = {
name: string
isDirectory: boolean
}
+type RemoteBrowseResult = {
+ entries: RemoteDirEntry[]
+ resolvedPath: string
+ pathFlavor: FilesystemPathFlavor
+}
+
const SSH_BROWSE_TIMEOUT_MS = 15_000
// Why: 127 = POSIX "command not found" (locale-independent) — the Windows fallback never ran, so the original POSIX error is the real one.
@@ -32,10 +39,7 @@ export function registerSshBrowseHandler(
ipcMain.handle(
'ssh:browseDir',
- async (
- _event,
- args: { targetId: string; dirPath: string }
- ): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> => {
+ async (_event, args: { targetId: string; dirPath: string }): Promise => {
const mgr = getConnectionManager()
if (!mgr) {
throw new Error('SSH connection manager not initialized')
@@ -68,15 +72,25 @@ type SshBrowseConnection = NonNullable {
+): Promise {
// Why: `command ls` skips aliases; `&&` makes a failing ls exit non-zero (not look empty); -1Ap = one-per-line + trailing / on dirs.
- return runBrowseCommand(conn, `cd ${shellEscape(dirPath)} && pwd && command ls -1Ap`)
+ return runBrowseCommand(conn, `cd ${shellEscape(dirPath)} && pwd && command ls -1Ap`, 'posix')
}
function browseWithWindowsPowerShell(
conn: SshBrowseConnection,
dirPath: string
-): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> {
+): Promise {
+ if (/^[\\/]+$/.test(dirPath.trim())) {
+ const script = [
+ "$ErrorActionPreference = 'Stop'",
+ '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
+ "Write-Output '/'",
+ "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Name -match '^[A-Za-z]$' } | Sort-Object Name | ForEach-Object { Write-Output (($_.Name.ToUpperInvariant() + ':\\') + '/') }"
+ ].join('; ')
+ return runBrowseCommand(conn, powerShellCommand(script), 'win32', { wrapCommand: false })
+ }
+
const script = [
"$ErrorActionPreference = 'Stop'",
// Why: PowerShell 5.1 emits redirected stdout in the OEM code page; pin UTF-8 so non-ASCII names aren't mojibake.
@@ -91,14 +105,15 @@ function browseWithWindowsPowerShell(
'}'
].join('; ')
- return runBrowseCommand(conn, powerShellCommand(script), { wrapCommand: false })
+ return runBrowseCommand(conn, powerShellCommand(script), 'win32', { wrapCommand: false })
}
async function runBrowseCommand(
conn: SshBrowseConnection,
command: string,
+ pathFlavor: FilesystemPathFlavor,
options?: SshExecOptions
-): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> {
+): Promise {
const channel = options ? await conn.exec(command, options) : await conn.exec(command)
return new Promise((resolve, reject) => {
@@ -145,7 +160,7 @@ async function runBrowseCommand(
rejectOnce(new Error('Remote directory listing timed out'))
closeChannel()
}
- const resolveOnce = (result: { entries: RemoteDirEntry[]; resolvedPath: string }): void => {
+ const resolveOnce = (result: RemoteBrowseResult): void => {
if (settled) {
return
}
@@ -213,7 +228,7 @@ async function runBrowseCommand(
return a.name.localeCompare(b.name)
})
- resolveOnce({ entries, resolvedPath })
+ resolveOnce({ entries, resolvedPath, pathFlavor })
}
channel.on('data', onStdoutData)
diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts
index 1e08d90d9ba..cbf0b08ba4f 100644
--- a/src/main/runtime/orca-runtime.test.ts
+++ b/src/main/runtime/orca-runtime.test.ts
@@ -6788,6 +6788,7 @@ describe('OrcaRuntimeService', () => {
const result = await runtime.browseServerDir(tempRoot)
expect(result.resolvedPath).toBe(tempRoot)
+ expect(result.pathFlavor).toBe(process.platform === 'win32' ? 'win32' : 'posix')
expect(result.entries).toEqual([
{ name: 'alpha', isDirectory: true, isSymlink: false },
{ name: 'zeta', isDirectory: true, isSymlink: false },
@@ -6798,6 +6799,20 @@ describe('OrcaRuntimeService', () => {
}
})
+ it.runIf(process.platform === 'win32')('lists drive roots for a server-root browse', async () => {
+ const runtime = new OrcaRuntimeService(store)
+
+ const result = await runtime.browseServerDir('/')
+
+ expect(result.resolvedPath).toBe('/')
+ expect(result.pathFlavor).toBe('win32')
+ expect(result.entries).toContainEqual({
+ name: win32.parse(tmpdir()).root.toUpperCase(),
+ isDirectory: true,
+ isSymlink: false
+ })
+ })
+
it('defaults runtime addRepo badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => {
const added: Record[] = []
const colorStore = {
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index aa4d66ada6d..792eb43eb07 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -10,6 +10,7 @@ import {
normalizeTerminalTitle
} from '../../shared/agent-detection'
import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail'
+import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing'
import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction'
import { parseFileUriPathParts } from '../daemon/osc7-file-uri'
import type { AgentStatus } from '../../shared/agent-detection'
@@ -185,6 +186,7 @@ import type {
WorkspaceCreateTelemetrySource,
WorkspaceSessionState,
DirEntry,
+ FilesystemPathFlavor,
GitHubIssueUpdate,
GitHubPullRequestStateUpdate,
GitHubPRFile,
@@ -15631,7 +15633,15 @@ export class OrcaRuntimeService {
return scanNestedRepos({ path, options: { timeoutMs: 15_000 } })
}
- async browseServerDir(pathValue: string): Promise<{ resolvedPath: string; entries: DirEntry[] }> {
+ async browseServerDir(pathValue: string): Promise<{
+ resolvedPath: string
+ entries: DirEntry[]
+ pathFlavor: FilesystemPathFlavor
+ }> {
+ // Windows resolves `/` to the current drive, so expose drive roots instead.
+ if (isServerDriveListRequest(pathValue)) {
+ return listWindowsDrives()
+ }
const dirPath = resolveServerBrowsePath(pathValue)
const dirStat = await stat(dirPath)
if (!dirStat.isDirectory()) {
@@ -15651,7 +15661,11 @@ export class OrcaRuntimeService {
}
return a.name.localeCompare(b.name)
})
- return { resolvedPath: dirPath, entries: mapped }
+ return {
+ resolvedPath: dirPath,
+ entries: mapped,
+ pathFlavor: process.platform === 'win32' ? 'win32' : 'posix'
+ }
}
async isGitAvailable(): Promise {
diff --git a/src/main/runtime/windows-drive-listing.test.ts b/src/main/runtime/windows-drive-listing.test.ts
new file mode 100644
index 00000000000..1d7387e7932
--- /dev/null
+++ b/src/main/runtime/windows-drive-listing.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from 'vitest'
+import type { Stats } from 'node:fs'
+import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing'
+
+describe('isServerDriveListRequest', () => {
+ it('matches root browses only on win32', () => {
+ expect(isServerDriveListRequest('/', 'win32')).toBe(true)
+ expect(isServerDriveListRequest('\\', 'win32')).toBe(true)
+ expect(isServerDriveListRequest(' / ', 'win32')).toBe(true)
+ expect(isServerDriveListRequest('/', 'darwin')).toBe(false)
+ expect(isServerDriveListRequest('/', 'linux')).toBe(false)
+ })
+
+ it('does not intercept non-root paths on win32', () => {
+ expect(isServerDriveListRequest('C:\\', 'win32')).toBe(false)
+ expect(isServerDriveListRequest('/Users', 'win32')).toBe(false)
+ expect(isServerDriveListRequest('~', 'win32')).toBe(false)
+ expect(isServerDriveListRequest('', 'win32')).toBe(false)
+ })
+})
+
+describe('listWindowsDrives', () => {
+ const statOnly = (mounted: string[]) => async (p: string) => {
+ if (!mounted.includes(p)) {
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ }
+ return { isDirectory: () => true } as Stats
+ }
+
+ it('returns one directory entry per mounted drive, anchored at the host root', async () => {
+ const result = await listWindowsDrives(statOnly(['C:\\', 'M:\\']))
+ expect(result.resolvedPath).toBe('/')
+ expect(result.pathFlavor).toBe('win32')
+ expect(result.entries).toEqual([
+ { name: 'C:\\', isDirectory: true, isSymlink: false },
+ { name: 'M:\\', isDirectory: true, isSymlink: false }
+ ])
+ })
+
+ it('skips letters whose stat fails or is not a directory', async () => {
+ const statPath = async (p: string): Promise => {
+ if (p === 'C:\\') {
+ return { isDirectory: () => true } as Stats
+ }
+ if (p === 'D:\\') {
+ return { isDirectory: () => false } as Stats
+ }
+ throw Object.assign(new Error('EPERM'), { code: 'EPERM' })
+ }
+ const result = await listWindowsDrives(statPath)
+ expect(result.entries.map((e) => e.name)).toEqual(['C:\\'])
+ })
+
+ it.each(['EIO', 'EMFILE'])('surfaces systemic %s failures', async (code) => {
+ const statPath = async (p: string): Promise => {
+ if (p === 'D:\\') {
+ throw Object.assign(new Error(code), { code })
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ }
+
+ await expect(listWindowsDrives(statPath)).rejects.toMatchObject({ code })
+ })
+})
diff --git a/src/main/runtime/windows-drive-listing.ts b/src/main/runtime/windows-drive-listing.ts
new file mode 100644
index 00000000000..9b2bbcfdbc0
--- /dev/null
+++ b/src/main/runtime/windows-drive-listing.ts
@@ -0,0 +1,57 @@
+import { stat } from 'node:fs/promises'
+import type { Stats } from 'node:fs'
+import type { FilesystemPathFlavor } from '../../shared/types'
+
+export type DriveListing = {
+ resolvedPath: string
+ entries: { name: string; isDirectory: boolean; isSymlink: boolean }[]
+ pathFlavor: FilesystemPathFlavor
+}
+
+// Windows has no shared filesystem root, so `/` represents mounted drives.
+export function isServerDriveListRequest(
+ pathValue: string,
+ platform: NodeJS.Platform = process.platform
+): boolean {
+ return platform === 'win32' && /^[\\/]+$/.test(pathValue.trim())
+}
+
+const DRIVE_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
+const EXPECTED_UNAVAILABLE_DRIVE_CODES = new Set(['EACCES', 'ENOENT', 'ENOTDIR', 'EPERM'])
+
+export async function listWindowsDrives(
+ statPath: (p: string) => Promise = stat
+): Promise {
+ // Keep the separator because bare `M:` is drive-relative on Windows.
+ const roots = await Promise.all(
+ [...DRIVE_LETTERS].map(async (letter) => {
+ const root = `${letter}:\\`
+ try {
+ const stats = await statPath(root)
+ return stats.isDirectory() ? root : null
+ } catch (error) {
+ if (isExpectedUnavailableDriveError(error)) {
+ return null
+ }
+ throw error
+ }
+ })
+ )
+ return {
+ resolvedPath: '/',
+ pathFlavor: 'win32',
+ entries: roots
+ .filter((root): root is string => root !== null)
+ .map((root) => ({ name: root, isDirectory: true, isSymlink: false }))
+ }
+}
+
+function isExpectedUnavailableDriveError(error: unknown): boolean {
+ return (
+ typeof error === 'object' &&
+ error !== null &&
+ 'code' in error &&
+ typeof error.code === 'string' &&
+ EXPECTED_UNAVAILABLE_DRIVE_CODES.has(error.code)
+ )
+}
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index 88cef637434..180e48d2d65 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -126,6 +126,7 @@ import type {
CustomPet,
DetectedWorktreeListResult,
DirEntry,
+ FilesystemPathFlavor,
ForceDeleteWorktreeBranchResult,
FsChangedPayload,
GhosttyImportPreview,
@@ -3402,6 +3403,7 @@ export type PreloadApi = {
browseDir: (args: { targetId: string; dirPath: string }) => Promise<{
entries: { name: string; isDirectory: boolean }[]
resolvedPath: string
+ pathFlavor: FilesystemPathFlavor
}>
onCredentialRequest: (
callback: (data: {
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 524ff3ede7b..22f05f2dc38 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -54,6 +54,7 @@ import type {
BrowserViewportOverride,
CustomPet,
FsChangedPayload,
+ FilesystemPathFlavor,
GetRateLimitResult,
GitHubPRRefreshCandidate,
GitHubPRRefreshEvent,
@@ -4451,6 +4452,7 @@ const api = {
}): Promise<{
entries: { name: string; isDirectory: boolean }[]
resolvedPath: string
+ pathFlavor: FilesystemPathFlavor
}> => ipcRenderer.invoke('ssh:browseDir', args),
onCredentialRequest: (
diff --git a/src/renderer/src/components/sidebar/RemoteFileBrowser.paste.test.tsx b/src/renderer/src/components/sidebar/RemoteFileBrowser.paste.test.tsx
index cc5c523562e..9d3aa7a3d98 100644
--- a/src/renderer/src/components/sidebar/RemoteFileBrowser.paste.test.tsx
+++ b/src/renderer/src/components/sidebar/RemoteFileBrowser.paste.test.tsx
@@ -10,12 +10,17 @@ type BrowseDirArgs = {
targetId: string
}
+let browsePathFlavor: 'posix' | 'win32' = 'posix'
+let browseEntries = [
+ { name: 'src', isDirectory: true },
+ { name: 'README.md', isDirectory: false }
+]
+
const browseDir = vi.fn(async ({ dirPath }: BrowseDirArgs) => ({
- entries: [
- { name: 'src', isDirectory: true },
- { name: 'README.md', isDirectory: false }
- ],
- resolvedPath: dirPath === '~' ? '/home/alice' : dirPath
+ entries: browseEntries,
+ resolvedPath:
+ dirPath === '~' ? (browsePathFlavor === 'win32' ? 'C:/Users/alice' : '/home/alice') : dirPath,
+ pathFlavor: browsePathFlavor
}))
async function flushPromises(count = 4): Promise {
@@ -65,6 +70,11 @@ describe('RemoteFileBrowser paste-sized input', () => {
beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
vi.useFakeTimers()
+ browsePathFlavor = 'posix'
+ browseEntries = [
+ { name: 'src', isDirectory: true },
+ { name: 'README.md', isDirectory: false }
+ ]
browseDir.mockClear()
Object.defineProperty(window, 'api', {
configurable: true,
@@ -98,6 +108,44 @@ describe('RemoteFileBrowser paste-sized input', () => {
})
})
+ it('navigates a typed Windows drive root and renders its breadcrumb', async () => {
+ browsePathFlavor = 'win32'
+ const { container, input, root } = await renderRemoteFileBrowser()
+ browseDir.mockClear()
+
+ await changeInput(input, 'M:\\')
+ await advancePathResolveDebounce()
+
+ expect(browseDir).toHaveBeenCalledWith({ targetId: 'target-1', dirPath: 'M:\\' })
+ await act(async () => {
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
+ await flushPromises()
+ })
+ expect(
+ [...container.querySelectorAll('button')].some((button) => button.textContent === 'M:')
+ ).toBe(true)
+
+ await act(async () => {
+ root.unmount()
+ })
+ })
+
+ it('keeps a drive-shaped POSIX directory as an ordinary filtered row', async () => {
+ browseEntries = [{ name: 'M:\\', isDirectory: true }]
+ const { container, input, root } = await renderRemoteFileBrowser()
+ browseDir.mockClear()
+
+ await changeInput(input, 'M:\\')
+ await advancePathResolveDebounce()
+
+ expect(browseDir).not.toHaveBeenCalled()
+ expect(container.textContent).toContain('M:\\')
+
+ await act(async () => {
+ root.unmount()
+ })
+ })
+
it('does not parse or remotely resolve oversized slash-containing paste text', async () => {
const { container, input, root } = await renderRemoteFileBrowser()
const pastedSecretPathList = 'C:/Users/alice/project/secret-token-value.txt\n'.repeat(2_000)
diff --git a/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx b/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
index 7673e023192..dc43f2bb8fe 100644
--- a/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
+++ b/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
@@ -17,8 +17,10 @@ import {
shouldDeferRemoteFileBrowserPasteResolve,
type DirEntry
} from './remote-file-browser-helpers'
+import { driveBreadcrumbPath, splitBrowsePath } from './remote-file-browser-drive-paths'
import { browseRuntimeServerDirectory } from '@/runtime/runtime-server-directory-browser'
import { translate } from '@/i18n/i18n'
+import type { FilesystemPathFlavor } from '../../../../shared/types'
type RemoteFileBrowserProps = (
| { targetId: string; runtimeEnvironmentId?: never }
@@ -33,7 +35,11 @@ const FILE_HINT_MS = 2000
const FILE_HINT_TEXT = "Files can't be opened as a project"
const PATH_DEBOUNCE_MS = 300
-type BrowseResult = { resolvedPath: string; entries: DirEntry[] }
+type BrowseResult = {
+ resolvedPath: string
+ entries: DirEntry[]
+ pathFlavor: FilesystemPathFlavor
+}
type PreviewState = {
resolvedPath: string
@@ -52,6 +58,7 @@ export function RemoteFileBrowser({
}: RemoteFileBrowserProps): React.JSX.Element {
const [resolvedPath, setResolvedPath] = useState('')
const [entries, setEntries] = useState([])
+ const [pathFlavor, setPathFlavor] = useState('posix')
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [filter, setFilter] = useState('')
@@ -142,6 +149,7 @@ export function RemoteFileBrowser({
}
setResolvedPath(result.resolvedPath)
setEntries(result.entries)
+ setPathFlavor(result.pathFlavor)
// Only bare `~` yields the home dir itself; `~/sub` resolves elsewhere and must not overwrite the home anchor.
if (dirPath === '~') {
homePathRef.current = result.resolvedPath
@@ -184,17 +192,17 @@ export function RemoteFileBrowser({
const navigateInto = useCallback(
(name: string) => {
- navigate(joinPath(resolvedPath, name))
+ navigate(joinPath(resolvedPath, name, pathFlavor))
},
- [resolvedPath, navigate]
+ [resolvedPath, navigate, pathFlavor]
)
const navigateUp = useCallback(() => {
if (resolvedPath === '/') {
return
}
- navigate(parentPath(resolvedPath))
- }, [resolvedPath, navigate])
+ navigate(parentPath(resolvedPath, pathFlavor))
+ }, [resolvedPath, navigate, pathFlavor])
const filteredEntries = useMemo(() => filterEntries(entries, filter), [entries, filter])
@@ -217,7 +225,7 @@ export function RemoteFileBrowser({
// Resolve a path-mode input into preview state; stable callback so paste and the debounce tick share one instance.
const resolvePathInput = useCallback(
async (raw: string) => {
- const parsed = parsePathInput(raw)
+ const parsed = parsePathInput(raw, pathFlavor)
if (parsed.mode !== 'path') {
return
}
@@ -238,6 +246,8 @@ export function RemoteFileBrowser({
let basePath: string
if (parsed.base === 'root') {
basePath = '/'
+ } else if (parsed.base === 'drive') {
+ basePath = parsed.driveRoot ?? '/'
} else if (parsed.base === 'home') {
if (!homePathRef.current) {
setPreview({
@@ -300,11 +310,11 @@ export function RemoteFileBrowser({
}
if (outcome.type === 'stay') {
if (segment === '..') {
- currentPath = parentPath(currentPath)
+ currentPath = parentPath(currentPath, listing.pathFlavor)
}
continue
}
- currentPath = joinPath(currentPath, outcome.name)
+ currentPath = joinPath(currentPath, outcome.name, listing.pathFlavor)
}
const finalListing = await fetchListing(currentPath)
@@ -332,7 +342,7 @@ export function RemoteFileBrowser({
})
}
},
- [resolvedPath, fetchListing]
+ [resolvedPath, fetchListing, pathFlavor]
)
// Filter-mode edits stay local; path-mode edits trigger a debounced resolve, but trailing-filter-only edits stay local too.
@@ -357,7 +367,7 @@ export function RemoteFileBrowser({
return
}
- if (!isPathMode(raw)) {
+ if (!isPathMode(raw, pathFlavor)) {
// Leaving path mode: drop preview immediately so the committed directory reappears without a flicker.
if (preview) {
setPreview(null)
@@ -370,7 +380,7 @@ export function RemoteFileBrowser({
return
}
- const parsed = parsePathInput(raw)
+ const parsed = parsePathInput(raw, pathFlavor)
// Fast path: unchanged committed prefix updates only the local filter, so intra-segment typing issues no browseDir call.
if (
parsed.mode === 'path' &&
@@ -392,7 +402,7 @@ export function RemoteFileBrowser({
resolvePathInput(raw)
}, PATH_DEBOUNCE_MS)
},
- [clearFileHint, preview, resolvePathInput]
+ [clearFileHint, preview, resolvePathInput, pathFlavor]
)
const handleInputPaste = useCallback(
@@ -414,12 +424,12 @@ export function RemoteFileBrowser({
debounceTimerRef.current = null
}
const value = inputRef.current?.value ?? ''
- if (!isRemoteFileBrowserPathResolveTextTooLarge(value) && isPathMode(value)) {
+ if (!isRemoteFileBrowserPathResolveTextTooLarge(value) && isPathMode(value, pathFlavor)) {
resolvePathInput(value)
}
}, 0)
},
- [resolvePathInput]
+ [resolvePathInput, pathFlavor]
)
// Select always returns the committed directory; disabled during a path preview to avoid a mismatched selection.
@@ -442,13 +452,13 @@ export function RemoteFileBrowser({
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = null
if (entry.isDirectory) {
- navigate(joinPath(listParentPath, entry.name))
+ navigate(joinPath(listParentPath, entry.name, pathFlavor))
} else {
triggerFileHint()
}
}, 220)
},
- [navigate, triggerFileHint, listParentPath, preview?.loading]
+ [navigate, triggerFileHint, listParentPath, preview?.loading, pathFlavor]
)
const handleRowDoubleClick = useCallback(
@@ -461,9 +471,9 @@ export function RemoteFileBrowser({
clearTimeout(clickTimerRef.current)
clickTimerRef.current = null
}
- onSelect(joinPath(listParentPath, entry.name))
+ onSelect(joinPath(listParentPath, entry.name, pathFlavor))
},
- [listParentPath, onSelect, preview?.loading]
+ [listParentPath, onSelect, preview?.loading, pathFlavor]
)
const handleFilterKeyDown = useCallback(
@@ -475,7 +485,7 @@ export function RemoteFileBrowser({
e.preventDefault()
return
}
- const parsed = parsePathInput(filter)
+ const parsed = parsePathInput(filter, pathFlavor)
// Fully-resolved directory (trailing `/` or bare base marker): navigate to the preview path itself.
if (parsed.mode === 'path' && parsed.trailingFilter === '') {
e.preventDefault()
@@ -487,7 +497,7 @@ export function RemoteFileBrowser({
const action = decideEnterAction(filtered)
if (action.type === 'navigate') {
e.preventDefault()
- navigate(joinPath(preview.resolvedPath, action.name))
+ navigate(joinPath(preview.resolvedPath, action.name, pathFlavor))
} else if (action.type === 'fileHint') {
e.preventDefault()
triggerFileHint()
@@ -542,11 +552,21 @@ export function RemoteFileBrowser({
resolvedPath,
triggerFileHint,
clearFileHint,
- onCancel
+ onCancel,
+ pathFlavor
]
)
- const pathSegments = resolvedPath.split('/').filter(Boolean)
+ // Preserve the separator shape when rebuilding drive breadcrumbs.
+ const browseParts = splitBrowsePath(resolvedPath, pathFlavor)
+ const pathSegments = browseParts.segments
+ const breadcrumbPathTo = useCallback(
+ (segmentIndex: number): string =>
+ browseParts.kind === 'drive'
+ ? driveBreadcrumbPath(browseParts.driveRoot, browseParts.segments, segmentIndex)
+ : `/${browseParts.segments.slice(0, segmentIndex + 1).join('/')}`,
+ [browseParts]
+ )
// Render the preview listing (own filter/error) during path mode, the committed listing otherwise.
const isPreviewActive = preview !== null
@@ -598,12 +618,27 @@ export function RemoteFileBrowser({
>
/
- {pathSegments.map((segment, i) => (
-
+ {browseParts.kind === 'drive' && (
+ <>
navigate(`/${pathSegments.slice(0, i + 1).join('/')}`)}
+ onClick={() => navigate(browseParts.driveRoot)}
+ className={cn(
+ 'truncate max-w-[120px] hover:text-foreground transition-colors cursor-pointer px-0.5',
+ pathSegments.length === 0 && 'text-foreground font-medium'
+ )}
+ >
+ {browseParts.driveRoot.slice(0, 2)}
+
+ >
+ )}
+ {pathSegments.map((segment, i) => (
+
+
+ navigate(breadcrumbPathTo(i))}
className={cn(
'truncate max-w-[120px] hover:text-foreground transition-colors cursor-pointer px-0.5',
i === pathSegments.length - 1 && 'text-foreground font-medium'
@@ -752,9 +787,9 @@ export function RemoteFileBrowser({
)
}
-// Portion of raw before the final `/`; lets callers tell a trailing-filter-only edit from a committed-segment change.
+// Portion before the final separator; distinguishes filter-only edits from committed-path changes.
function committedPrefix(raw: string): string {
- const i = raw.lastIndexOf('/')
+ const i = Math.max(raw.lastIndexOf('/'), raw.lastIndexOf('\\'))
return i === -1 ? '' : raw.slice(0, i + 1)
}
diff --git a/src/renderer/src/components/sidebar/remote-file-browser-drive-paths.test.ts b/src/renderer/src/components/sidebar/remote-file-browser-drive-paths.test.ts
new file mode 100644
index 00000000000..c33b79846b5
--- /dev/null
+++ b/src/renderer/src/components/sidebar/remote-file-browser-drive-paths.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from 'vitest'
+import {
+ driveBreadcrumbPath,
+ driveRootOf,
+ isDrivePath,
+ isDriveRoot,
+ joinDrivePath,
+ parentOfDrivePath,
+ splitBrowsePath
+} from './remote-file-browser-drive-paths'
+
+describe('isDrivePath', () => {
+ it('accepts drive anchors with either separator, bare colon, and any case', () => {
+ expect(isDrivePath('M:\\')).toBe(true)
+ expect(isDrivePath('M:/')).toBe(true)
+ expect(isDrivePath('m:')).toBe(true)
+ expect(isDrivePath('C:\\Users\\Administrator')).toBe(true)
+ })
+
+ it('rejects POSIX paths and ordinary filter text', () => {
+ expect(isDrivePath('/home/user')).toBe(false)
+ expect(isDrivePath('docs')).toBe(false)
+ expect(isDrivePath('M:x')).toBe(false)
+ expect(isDrivePath('12:\\')).toBe(false)
+ })
+})
+
+describe('driveRootOf / isDriveRoot', () => {
+ it('normalizes any drive anchor to an uppercase backslash root', () => {
+ expect(driveRootOf('m:/dev')).toBe('M:\\')
+ expect(driveRootOf('M:')).toBe('M:\\')
+ })
+
+ it('treats M:, M:\\ and M:/ as roots but not deeper paths', () => {
+ expect(isDriveRoot('M:')).toBe(true)
+ expect(isDriveRoot('M:\\')).toBe(true)
+ expect(isDriveRoot('M:/')).toBe(true)
+ expect(isDriveRoot('M:\\dev')).toBe(false)
+ })
+})
+
+describe('splitBrowsePath', () => {
+ it('splits drive paths on either separator', () => {
+ expect(splitBrowsePath('M:\\dev\\debox', 'win32')).toEqual({
+ kind: 'drive',
+ driveRoot: 'M:\\',
+ segments: ['dev', 'debox']
+ })
+ expect(splitBrowsePath('M:/dev', 'win32')).toEqual({
+ kind: 'drive',
+ driveRoot: 'M:\\',
+ segments: ['dev']
+ })
+ })
+
+ it('keeps POSIX paths in the POSIX shape', () => {
+ expect(splitBrowsePath('/home/user')).toEqual({ kind: 'posix', segments: ['home', 'user'] })
+ expect(splitBrowsePath('/')).toEqual({ kind: 'posix', segments: [] })
+ expect(splitBrowsePath('M:\\dev', 'posix')).toEqual({
+ kind: 'posix',
+ segments: ['M:\\dev']
+ })
+ })
+})
+
+describe('joinDrivePath / parentOfDrivePath', () => {
+ it('joins with a backslash without doubling the root separator', () => {
+ expect(joinDrivePath('M:\\', 'dev')).toBe('M:\\dev')
+ expect(joinDrivePath('M:\\dev', 'debox')).toBe('M:\\dev\\debox')
+ })
+
+ it('walks up to the drive root and then to the host root', () => {
+ expect(parentOfDrivePath('M:\\dev\\debox')).toBe('M:\\dev')
+ expect(parentOfDrivePath('M:\\dev')).toBe('M:\\')
+ expect(parentOfDrivePath('M:\\')).toBe('/')
+ })
+})
+
+describe('driveBreadcrumbPath', () => {
+ it('rebuilds absolute paths for breadcrumb clicks', () => {
+ const segments = ['dev', 'debox', 'repo']
+ expect(driveBreadcrumbPath('M:\\', segments, 0)).toBe('M:\\dev')
+ expect(driveBreadcrumbPath('M:\\', segments, 2)).toBe('M:\\dev\\debox\\repo')
+ expect(driveBreadcrumbPath('M:\\', [], -1)).toBe('M:\\')
+ })
+})
diff --git a/src/renderer/src/components/sidebar/remote-file-browser-drive-paths.ts b/src/renderer/src/components/sidebar/remote-file-browser-drive-paths.ts
new file mode 100644
index 00000000000..52e507aadf2
--- /dev/null
+++ b/src/renderer/src/components/sidebar/remote-file-browser-drive-paths.ts
@@ -0,0 +1,62 @@
+export type BrowsePathParts =
+ | { kind: 'posix'; segments: string[] }
+ | { kind: 'drive'; driveRoot: string; segments: string[] }
+
+// Bare `M:` is a root here because forwarding it would be drive-relative.
+const DRIVE_ANCHOR_RE = /^[A-Za-z]:([\\/]|$)/
+
+export function isDrivePath(p: string): boolean {
+ return DRIVE_ANCHOR_RE.test(p)
+}
+
+export function isDriveRoot(p: string): boolean {
+ return /^[A-Za-z]:[\\/]?$/.test(p)
+}
+
+export function driveRootOf(p: string): string {
+ return `${p[0].toUpperCase()}:\\`
+}
+
+export function splitBrowsePath(
+ p: string,
+ pathFlavor: FilesystemPathFlavor = 'posix'
+): BrowsePathParts {
+ if (pathFlavor === 'win32' && isDrivePath(p)) {
+ return {
+ kind: 'drive',
+ driveRoot: driveRootOf(p),
+ segments: p.slice(2).split(/[\\/]/).filter(Boolean)
+ }
+ }
+ return { kind: 'posix', segments: p.split('/').filter(Boolean) }
+}
+
+// Backslash targets the remote Windows host regardless of the client's platform.
+export function joinDrivePath(base: string, name: string): string {
+ return `${base.replace(/[\\/]+$/, '')}\\${name}`
+}
+
+// Up from a drive root returns to the host drive list.
+export function parentOfDrivePath(p: string): string {
+ if (isDriveRoot(p)) {
+ return '/'
+ }
+ const parts = splitBrowsePath(p, 'win32')
+ if (parts.kind !== 'drive') {
+ return p
+ }
+ const parentSegments = parts.segments.slice(0, -1)
+ return parentSegments.length === 0
+ ? parts.driveRoot
+ : `${parts.driveRoot}${parentSegments.join('\\')}`
+}
+
+export function driveBreadcrumbPath(
+ driveRoot: string,
+ segments: string[],
+ endIndex: number
+): string {
+ const kept = segments.slice(0, endIndex + 1)
+ return kept.length === 0 ? driveRoot : `${driveRoot}${kept.join('\\')}`
+}
+import type { FilesystemPathFlavor } from '../../../../shared/types'
diff --git a/src/renderer/src/components/sidebar/remote-file-browser-helpers.test.ts b/src/renderer/src/components/sidebar/remote-file-browser-helpers.test.ts
index c55344fc549..39283335f7a 100644
--- a/src/renderer/src/components/sidebar/remote-file-browser-helpers.test.ts
+++ b/src/renderer/src/components/sidebar/remote-file-browser-helpers.test.ts
@@ -7,6 +7,7 @@ import {
isRemoteFileBrowserFilterQueryTooLarge,
isRemoteFileBrowserPathResolveTextTooLarge,
isPathMode,
+ joinPath,
parentPath,
parsePathInput,
resolveSegmentStep,
@@ -336,3 +337,71 @@ describe('resolveSegmentStep', () => {
}
})
})
+
+describe('Windows drive paths', () => {
+ it('isPathMode triggers on drive-anchored input', () => {
+ expect(isPathMode('M:\\', 'win32')).toBe(true)
+ expect(isPathMode('M:/', 'win32')).toBe(true)
+ expect(isPathMode('m:', 'win32')).toBe(true)
+ expect(isPathMode('M:\\dev', 'win32')).toBe(true)
+ expect(isPathMode('M', 'win32')).toBe(false)
+ expect(isPathMode('M:x', 'win32')).toBe(false)
+ })
+
+ it('keeps drive-shaped POSIX names in filter and child-path semantics', () => {
+ expect(isPathMode('M:\\', 'posix')).toBe(false)
+ expect(parsePathInput('M:\\', 'posix')).toEqual({ mode: 'filter', filter: 'M:\\' })
+ expect(joinPath('/', 'M:\\', 'posix')).toBe('/M:\\')
+ expect(parentPath('/M:\\', 'posix')).toBe('/')
+ })
+
+ it('parsePathInput anchors drive input at the normalized drive root', () => {
+ expect(parsePathInput('m:/dev/', 'win32')).toEqual({
+ mode: 'path',
+ base: 'drive',
+ driveRoot: 'M:\\',
+ committedSegments: ['dev'],
+ trailingFilter: ''
+ })
+ expect(parsePathInput('M:\\dev\\deb', 'win32')).toEqual({
+ mode: 'path',
+ base: 'drive',
+ driveRoot: 'M:\\',
+ committedSegments: ['dev'],
+ trailingFilter: 'deb'
+ })
+ expect(parsePathInput('M:', 'win32')).toEqual({
+ mode: 'path',
+ base: 'drive',
+ driveRoot: 'M:\\',
+ committedSegments: [],
+ trailingFilter: ''
+ })
+ })
+
+ it('parsePathInput rejects repeated separators in drive input, either kind', () => {
+ for (const raw of ['M:\\dev\\\\x', 'M:/dev//x', 'M:\\dev\\/x']) {
+ const parsed = parsePathInput(raw, 'win32')
+ expect(parsed.mode).toBe('path')
+ if (parsed.mode === 'path') {
+ expect(parsed.invalid).toMatch(/repeated separators/)
+ }
+ }
+ })
+
+ it('joinPath treats drive rows in the host-root listing as absolute', () => {
+ expect(joinPath('/', 'M:\\', 'win32')).toBe('M:\\')
+ expect(joinPath('/', 'usr', 'win32')).toBe('/usr')
+ })
+
+ it('joinPath appends with a backslash inside a drive', () => {
+ expect(joinPath('M:\\', 'dev', 'win32')).toBe('M:\\dev')
+ expect(joinPath('M:\\dev', 'debox', 'win32')).toBe('M:\\dev\\debox')
+ })
+
+ it('parentPath climbs drive paths and exits to the host root', () => {
+ expect(parentPath('M:\\dev\\debox', 'win32')).toBe('M:\\dev')
+ expect(parentPath('M:\\dev', 'win32')).toBe('M:\\')
+ expect(parentPath('M:\\', 'win32')).toBe('/')
+ })
+})
diff --git a/src/renderer/src/components/sidebar/remote-file-browser-helpers.ts b/src/renderer/src/components/sidebar/remote-file-browser-helpers.ts
index 987dc07a4da..a9508a76364 100644
--- a/src/renderer/src/components/sidebar/remote-file-browser-helpers.ts
+++ b/src/renderer/src/components/sidebar/remote-file-browser-helpers.ts
@@ -1,6 +1,13 @@
import { translate } from '@/i18n/i18n'
import { shouldHandleTextControlPaste } from '@/lib/text-control-paste'
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
+import type { FilesystemPathFlavor } from '../../../../shared/types'
+import {
+ driveRootOf,
+ isDrivePath,
+ joinDrivePath,
+ parentOfDrivePath
+} from './remote-file-browser-drive-paths'
export type DirEntry = {
name: string
isDirectory: boolean
@@ -54,11 +61,25 @@ export function decideEscAction(filter: string): EscAction {
return filter.length > 0 ? { type: 'clearFilter' } : { type: 'cancel' }
}
-export function joinPath(resolvedPath: string, name: string): string {
+export function joinPath(
+ resolvedPath: string,
+ name: string,
+ pathFlavor: FilesystemPathFlavor = 'posix'
+): string {
+ // Drive rows in a Windows host-root listing are already absolute (`M:\`).
+ if (pathFlavor === 'win32' && resolvedPath === '/' && isDrivePath(name)) {
+ return driveRootOf(name)
+ }
+ if (pathFlavor === 'win32' && isDrivePath(resolvedPath)) {
+ return joinDrivePath(resolvedPath, name)
+ }
return resolvedPath === '/' ? `/${name}` : `${resolvedPath}/${name}`
}
-export function parentPath(p: string): string {
+export function parentPath(p: string, pathFlavor: FilesystemPathFlavor = 'posix'): string {
+ if (pathFlavor === 'win32' && isDrivePath(p)) {
+ return parentOfDrivePath(p)
+ }
if (p === '/' || p === '') {
return '/'
}
@@ -73,8 +94,10 @@ export type ParsedInput =
| {
mode: 'path'
// `root` = absolute `/`, `home` = resolved SSH user home, `cwd` = the
- // currently committed resolvedPath.
- base: 'root' | 'home' | 'cwd'
+ // currently committed resolvedPath, `drive` = a Windows drive root.
+ base: 'root' | 'home' | 'cwd' | 'drive'
+ // Canonical `M:\` root; only set when `base` is 'drive'.
+ driveRoot?: string
// Segments to resolve one-by-one from the base. Empty string segments
// never appear here — repeated separators are surfaced via `invalid`.
committedSegments: string[]
@@ -86,13 +109,14 @@ export type ParsedInput =
invalid?: string
}
-// Path mode triggers when the input contains `/` or is one of the three
-// base-marker literals (`~`, `.`, `..`). The literal `..` rule is required
-// because "contains /" alone would keep bare `..` in filter mode.
-export function isPathMode(raw: string): boolean {
+// Slashes, drive anchors, and standalone base markers enter path mode.
+export function isPathMode(raw: string, pathFlavor: FilesystemPathFlavor = 'posix'): boolean {
if (raw.includes('/')) {
return true
}
+ if (pathFlavor === 'win32' && isDrivePath(raw)) {
+ return true
+ }
return raw === '~' || raw === '.' || raw === '..'
}
@@ -104,8 +128,11 @@ export function shouldDeferRemoteFileBrowserPasteResolve(text: string): boolean
return isRemoteFileBrowserPathResolveTextTooLarge(text)
}
-export function parsePathInput(raw: string): ParsedInput {
- if (!isPathMode(raw)) {
+export function parsePathInput(
+ raw: string,
+ pathFlavor: FilesystemPathFlavor = 'posix'
+): ParsedInput {
+ if (!isPathMode(raw, pathFlavor)) {
// Filter mode preserves the raw text; trimming happens inside
// `filterEntries` so leading/trailing spaces don't alter the input shown
// back to the user.
@@ -123,9 +150,15 @@ export function parsePathInput(raw: string): ParsedInput {
return { mode: 'path', base: 'cwd', committedSegments: ['..'], trailingFilter: '' }
}
- let base: 'root' | 'home' | 'cwd'
+ let base: 'root' | 'home' | 'cwd' | 'drive'
+ let driveRoot: string | undefined
let remainder: string
- if (raw.startsWith('/')) {
+ if (pathFlavor === 'win32' && isDrivePath(raw)) {
+ base = 'drive'
+ driveRoot = driveRootOf(raw)
+ // Strip the drive anchor; segments accept either Windows separator.
+ remainder = raw.slice(2).replace(/^[\\/]/, '')
+ } else if (raw.startsWith('/')) {
base = 'root'
remainder = raw.slice(1)
} else if (raw.startsWith('~/')) {
@@ -138,10 +171,13 @@ export function parsePathInput(raw: string): ParsedInput {
// Don't collapse `//`: the visible input must agree with the path being
// resolved. Report it as invalid and let the caller surface the error.
- if (remainder.includes('//')) {
+ const hasRepeatedSeparators =
+ base === 'drive' ? /[\\/]{2,}/.test(remainder) : remainder.includes('//')
+ if (hasRepeatedSeparators) {
return {
mode: 'path',
base,
+ driveRoot,
committedSegments: [],
trailingFilter: '',
invalid: 'Invalid path: repeated separators'
@@ -159,6 +195,7 @@ export function parsePathInput(raw: string): ParsedInput {
return {
mode: 'path',
base,
+ driveRoot,
committedSegments: [],
trailingFilter: '',
invalid: 'Invalid path: control characters are not allowed'
@@ -167,11 +204,12 @@ export function parsePathInput(raw: string): ParsedInput {
// `split('/')` leaves an empty string when `remainder` ends with `/`, which
// is the only legal "empty tail" and simply means "no trailing filter".
- const parts = remainder === '' ? [''] : remainder.split('/')
+ const parts =
+ remainder === '' ? [''] : base === 'drive' ? remainder.split(/[\\/]/) : remainder.split('/')
const trailingFilter = parts.at(-1) ?? ''
const committedSegments = parts.slice(0, -1)
- return { mode: 'path', base, committedSegments, trailingFilter }
+ return { mode: 'path', base, driveRoot, committedSegments, trailingFilter }
}
export type SegmentOutcome =
diff --git a/src/renderer/src/runtime/runtime-server-directory-browser.test.ts b/src/renderer/src/runtime/runtime-server-directory-browser.test.ts
index fea0a918310..07740c37726 100644
--- a/src/renderer/src/runtime/runtime-server-directory-browser.test.ts
+++ b/src/renderer/src/runtime/runtime-server-directory-browser.test.ts
@@ -28,6 +28,7 @@ beforeEach(() => {
ok: true,
result: {
resolvedPath: '/home/me',
+ pathFlavor: 'posix',
entries: [{ name: 'repo', isDirectory: true, isSymlink: false }]
},
_meta: { runtimeId: 'remote-runtime' }
@@ -46,6 +47,7 @@ describe('runtime server directory browser', () => {
it('routes browse requests through the selected runtime environment', async () => {
await expect(browseRuntimeServerDirectory('env-1', '~')).resolves.toEqual({
resolvedPath: '/home/me',
+ pathFlavor: 'posix',
entries: [{ name: 'repo', isDirectory: true, isSymlink: false }]
})
diff --git a/src/renderer/src/runtime/runtime-server-directory-browser.ts b/src/renderer/src/runtime/runtime-server-directory-browser.ts
index a5b0eff00e7..5d7c0a13ade 100644
--- a/src/renderer/src/runtime/runtime-server-directory-browser.ts
+++ b/src/renderer/src/runtime/runtime-server-directory-browser.ts
@@ -1,9 +1,10 @@
-import type { DirEntry } from '../../../shared/types'
+import type { DirEntry, FilesystemPathFlavor } from '../../../shared/types'
import { callRuntimeRpc } from './runtime-rpc-client'
export type RuntimeServerDirectoryListing = {
resolvedPath: string
entries: DirEntry[]
+ pathFlavor: FilesystemPathFlavor
}
export async function browseRuntimeServerDirectory(
diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts
index 4c30a983555..0840e20bab2 100644
--- a/src/renderer/src/web/web-preload-api.ts
+++ b/src/renderer/src/web/web-preload-api.ts
@@ -3122,7 +3122,7 @@ function createSshApi(): NonNullable['ssh']> {
listDetectedPorts: () => Promise.resolve([]),
onPortForwardsChanged: () => noopUnsubscribe,
onDetectedPortsChanged: () => noopUnsubscribe,
- browseDir: () => Promise.resolve({ entries: [], resolvedPath: '' }),
+ browseDir: () => Promise.resolve({ entries: [], resolvedPath: '', pathFlavor: 'posix' }),
onCredentialRequest: () => noopUnsubscribe,
onCredentialResolved: () => noopUnsubscribe,
submitCredential: () => Promise.resolve()
diff --git a/src/shared/types.ts b/src/shared/types.ts
index b98f3d1ea1b..35c967ceadf 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -3563,6 +3563,8 @@ export type PersistedState = {
}
// ─── Filesystem ─────────────────────────────────────────────
+export type FilesystemPathFlavor = 'posix' | 'win32'
+
export type DirEntry = {
name: string
isDirectory: boolean
diff --git a/tests/e2e/helpers/nested-runtime-same-id-pairing.ts b/tests/e2e/helpers/nested-runtime-same-id-pairing.ts
index 0803f1ebf3a..5e13630de80 100644
--- a/tests/e2e/helpers/nested-runtime-same-id-pairing.ts
+++ b/tests/e2e/helpers/nested-runtime-same-id-pairing.ts
@@ -33,7 +33,7 @@ export async function replaceRuntimePairingInPlace(args: {
if (!(await store.getState().refreshRuntimeEnvironmentStatus(selector))) {
throw new Error('Same-ID re-paired desktop could not reach the HUB runtime')
}
- if (!(await store.getState().switchRuntimeEnvironment(selector))) {
+ if (!(await store.getState().setActiveRuntimeEnvironmentPreference(selector))) {
throw new Error('Same-ID re-paired desktop could not select the HUB runtime')
}
// Why: same-ID selection is a no-op, so explicitly rehydrate the graph from the replacement transport.
diff --git a/tests/e2e/helpers/nested-runtime-ssh-client-route.ts b/tests/e2e/helpers/nested-runtime-ssh-client-route.ts
index 933a2a97121..bdef927c40a 100644
--- a/tests/e2e/helpers/nested-runtime-ssh-client-route.ts
+++ b/tests/e2e/helpers/nested-runtime-ssh-client-route.ts
@@ -293,7 +293,7 @@ export async function addPairedRuntimeEnvironment(
if (!(await store.getState().refreshRuntimeEnvironmentStatus(result.environment.id))) {
throw new Error(`Paired desktop could not reach ${name}`)
}
- if (!(await store.getState().switchRuntimeEnvironment(result.environment.id))) {
+ if (!(await store.getState().setActiveRuntimeEnvironmentPreference(result.environment.id))) {
throw new Error(`Paired desktop could not select ${name}`)
}
return result.environment.id
diff --git a/tests/e2e/helpers/paired-electron-client.ts b/tests/e2e/helpers/paired-electron-client.ts
index 644b8520da1..67427f3b354 100644
--- a/tests/e2e/helpers/paired-electron-client.ts
+++ b/tests/e2e/helpers/paired-electron-client.ts
@@ -198,7 +198,9 @@ export async function launchPairedElectronClient(
if (!(await store.getState().refreshRuntimeEnvironmentStatus(result.environment.id))) {
throw new Error('Paired desktop could not reach the HUB runtime')
}
- if (!(await store.getState().switchRuntimeEnvironment(result.environment.id))) {
+ if (
+ !(await store.getState().setActiveRuntimeEnvironmentPreference(result.environment.id))
+ ) {
throw new Error('Paired desktop could not select the HUB runtime')
}
return result.environment.id
@@ -263,7 +265,7 @@ export async function rePairPairedElectronClient(
if (!(await store.getState().refreshRuntimeEnvironmentStatus(result.environment.id))) {
throw new Error('Re-paired desktop could not reach the HUB runtime')
}
- if (!(await store.getState().switchRuntimeEnvironment(result.environment.id))) {
+ if (!(await store.getState().setActiveRuntimeEnvironmentPreference(result.environment.id))) {
throw new Error('Re-paired desktop could not select the HUB runtime')
}
return result.environment.id
@@ -291,7 +293,7 @@ export async function rePairPairedElectronClient(
if (!(await store.getState().refreshRuntimeEnvironmentStatus(nextEnvironmentId))) {
return false
}
- return store.getState().switchRuntimeEnvironment(nextEnvironmentId)
+ return store.getState().setActiveRuntimeEnvironmentPreference(nextEnvironmentId)
}, environmentId)
if (!reachable) {
throw new Error('Re-paired desktop could not reach the HUB after reload')
diff --git a/tests/e2e/runtime-file-browser-windows-drives.spec.ts b/tests/e2e/runtime-file-browser-windows-drives.spec.ts
new file mode 100644
index 00000000000..d1bfbdef609
--- /dev/null
+++ b/tests/e2e/runtime-file-browser-windows-drives.spec.ts
@@ -0,0 +1,53 @@
+import os from 'node:os'
+import path from 'node:path'
+import { test, expect } from './helpers/orca-app'
+import {
+ createRuntimeDesktopPairingOffer,
+ launchPairedElectronClient
+} from './helpers/paired-electron-client'
+import { waitForSessionReady } from './helpers/store'
+
+test.describe('paired runtime Windows file browser', () => {
+ test.skip(process.platform !== 'win32', 'Windows drive roots require a Windows runtime host')
+
+ test('reports the runtime path flavor with Windows drive roots', async ({
+ orcaPage
+ }, testInfo) => {
+ test.setTimeout(120_000)
+ await waitForSessionReady(orcaPage)
+ const offer = await createRuntimeDesktopPairingOffer(orcaPage)
+ const client = await launchPairedElectronClient(offer, testInfo, 'Windows drive browser')
+
+ try {
+ const driveRoot = path.parse(os.tmpdir()).root.toUpperCase()
+ const listing = await client.page.evaluate(async () => {
+ const [environment] = await window.api.runtimeEnvironments.list()
+ if (!environment) {
+ throw new Error('Paired client runtime environment is unavailable')
+ }
+ const response = await window.api.runtimeEnvironments.call({
+ selector: environment.id,
+ method: 'files.browseServerDir',
+ params: { path: '/' },
+ timeoutMs: 15_000
+ })
+ if (!response.ok) {
+ throw new Error(response.error.message)
+ }
+ return response.result as {
+ pathFlavor: string
+ entries: { name: string; isDirectory: boolean }[]
+ }
+ })
+
+ expect(listing.pathFlavor).toBe('win32')
+ expect(listing.entries).toContainEqual({
+ name: driveRoot,
+ isDirectory: true,
+ isSymlink: false
+ })
+ } finally {
+ await client.dispose()
+ }
+ })
+})
From adc10cd21ab33812582c5dbd2fa07e9430bcd940 Mon Sep 17 00:00:00 2001
From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com>
Date: Wed, 29 Jul 2026 12:52:09 +0900
Subject: [PATCH 12/67] fix(explorer): refresh tree on create/rename with
case-tolerant cache keys (#10392)
* fix(explorer): refresh tree on create/rename with case-tolerant cache keys
Windows watchers can emit paths whose casing differs from the worktree
dirCache key, so create events never refreshed. Also apply rename events
immediately by refreshing the parent listing instead of ignoring them.
* fix(explorer): reconcile Windows update-only creates
* fix(explorer): bound watcher update reconciliation
* fix(explorer): index watcher cache paths
* fix(explorer): avoid expanded directory rescan
* fix(explorer): batch watcher subtree purges
* fix(explorer): preserve Windows drive roots
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
---
.../file-explorer-watch-drive-root.test.ts | 70 +++
.../right-sidebar/file-explorer-watch-path.ts | 92 ++++
.../file-explorer-watch-reconcile.test.ts | 441 ++++++++++++++++++
.../file-explorer-watch-reconcile.ts | 224 +++++++++
.../file-explorer-watcher-reconcile.ts | 54 ++-
.../useFileExplorerWatch.test.ts | 40 +-
.../right-sidebar/useFileExplorerWatch.ts | 181 +------
tests/e2e/file-explorer-watch-refresh.spec.ts | 59 +++
8 files changed, 967 insertions(+), 194 deletions(-)
create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-watch-drive-root.test.ts
create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts
create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts
create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.ts
create mode 100644 tests/e2e/file-explorer-watch-refresh.spec.ts
diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-drive-root.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-drive-root.test.ts
new file mode 100644
index 00000000000..259d3da312c
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-drive-root.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { FsChangeEvent } from '../../../../shared/types'
+import type { DirCache } from './file-explorer-types'
+import { processFileExplorerFsPayload } from './file-explorer-watch-reconcile'
+
+const DRIVE_ROOTS = [
+ { label: 'backslash', root: 'C:\\', child: (name: string) => `c:\\${name}` },
+ { label: 'slash', root: 'C:/', child: (name: string) => `c:/${name}` }
+]
+
+function processRootEvent(root: string, event: FsChangeEvent): ReturnType {
+ const refreshDir = vi.fn()
+ const rootCache: DirCache = {
+ children: [],
+ loading: false,
+ operationOwner: { kind: 'local' }
+ }
+
+ processFileExplorerFsPayload({
+ payload: { worktreePath: root, events: [event] },
+ currentWorktreePath: root,
+ worktreeId: 'folder::drive-root',
+ cache: { [root]: rootCache },
+ expanded: new Set(),
+ setDirCache: vi.fn(),
+ setSelectedPath: vi.fn(),
+ refreshDir,
+ refreshTree: vi.fn()
+ })
+
+ return refreshDir
+}
+
+describe('Windows drive-root watcher reconciliation', () => {
+ it.each(DRIVE_ROOTS)(
+ 'refreshes a $label drive root for an update-only create',
+ ({ root, child }) => {
+ const refreshDir = processRootEvent(root, {
+ kind: 'update',
+ absolutePath: child('new-file.txt'),
+ isDirectory: false
+ })
+
+ expect(refreshDir).toHaveBeenCalledOnce()
+ expect(refreshDir).toHaveBeenCalledWith(root)
+ }
+ )
+
+ it.each(DRIVE_ROOTS)('refreshes a $label drive root after a delete', ({ root, child }) => {
+ const refreshDir = processRootEvent(root, {
+ kind: 'delete',
+ absolutePath: child('removed-file.txt')
+ })
+
+ expect(refreshDir).toHaveBeenCalledOnce()
+ expect(refreshDir).toHaveBeenCalledWith(root)
+ })
+
+ it.each(DRIVE_ROOTS)('refreshes a $label drive root once after a rename', ({ root, child }) => {
+ const refreshDir = processRootEvent(root, {
+ kind: 'rename',
+ oldAbsolutePath: child('old-file.txt'),
+ absolutePath: child('new-file.txt'),
+ isDirectory: false
+ })
+
+ expect(refreshDir).toHaveBeenCalledOnce()
+ expect(refreshDir).toHaveBeenCalledWith(root)
+ })
+})
diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts
new file mode 100644
index 00000000000..5273408fe94
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts
@@ -0,0 +1,92 @@
+import { joinPath, dirname, normalizeRelativePath } from '@/lib/path'
+import {
+ normalizeRuntimePathForComparison,
+ relativePathInsideRoot
+} from '../../../../shared/cross-platform-path'
+
+export function normalizeExplorerAbsolutePath(path: string): string {
+ return path === '/' || /^[A-Za-z]:[\\/]$/.test(path) ? path : path.replace(/[\\/]+$/, '')
+}
+
+export function getExternalFileChangeRelativePath(
+ worktreePath: string,
+ absolutePath: string,
+ isDirectory: boolean | undefined
+): string | null {
+ if (isDirectory === true) {
+ return null
+ }
+
+ const relativePath = relativePathInsideRoot(worktreePath, absolutePath)
+ if (relativePath === null || relativePath === '') {
+ return null
+ }
+
+ // Why: EditorPanel reloads tabs only from a worktree-relative path, not the watcher's absolute one; normalize or contents go stale.
+ return normalizeRelativePath(relativePath)
+}
+
+export function canonicalizeFileExplorerWatchPath(
+ worktreePath: string,
+ absolutePath: string
+): string | null {
+ const relativePath = relativePathInsideRoot(worktreePath, absolutePath)
+ if (relativePath === null) {
+ return null
+ }
+
+ const rootPath = normalizeExplorerAbsolutePath(worktreePath)
+ return relativePath === '' ? rootPath : joinPath(rootPath, relativePath)
+}
+
+export function createCachedDirPathIndex(
+ cache: Record
+): ReadonlyMap {
+ const index = new Map()
+ for (const key of Object.keys(cache)) {
+ const normalizedKey = normalizeRuntimePathForComparison(key)
+ if (!index.has(normalizedKey)) {
+ index.set(normalizedKey, key)
+ }
+ }
+ return index
+}
+
+/**
+ * Map an event path to the dirCache key that should be refreshed.
+ * Windows watchers often differ in drive-letter casing from the worktree key.
+ */
+export function resolveCachedDirPath(
+ cache: Record,
+ dirPath: string,
+ worktreePath?: string,
+ cachePathIndex?: ReadonlyMap
+): string | null {
+ if (dirPath in cache) {
+ return dirPath
+ }
+ const target = normalizeRuntimePathForComparison(dirPath)
+ const indexedPath = cachePathIndex?.get(target)
+ if (indexedPath) {
+ return indexedPath
+ }
+ if (!cachePathIndex) {
+ for (const key of Object.keys(cache)) {
+ if (normalizeRuntimePathForComparison(key) === target) {
+ return key
+ }
+ }
+ }
+ if (worktreePath && normalizeRuntimePathForComparison(worktreePath) === target) {
+ return normalizeExplorerAbsolutePath(worktreePath)
+ }
+ return null
+}
+
+export function parentDirForWatchPath(normalizedPath: string): string {
+ const parentPath = dirname(normalizedPath)
+ if (/^[A-Za-z]:$/.test(parentPath)) {
+ return `${parentPath}${normalizedPath.includes('\\') ? '\\' : '/'}`
+ }
+ return normalizeExplorerAbsolutePath(parentPath)
+}
diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts
new file mode 100644
index 00000000000..d62420f7915
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts
@@ -0,0 +1,441 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { DirCache, TreeNode } from './file-explorer-types'
+import { processFileExplorerFsPayload } from './file-explorer-watch-reconcile'
+import { purgeDirCacheSubtrees } from './file-explorer-watcher-reconcile'
+import { useAppStore } from '@/store'
+
+function cacheWithChildren(paths: string[]): DirCache {
+ return {
+ children: paths.map(
+ (path): TreeNode => ({
+ name: path.split(/[\\/]/).at(-1) ?? path,
+ path,
+ relativePath: path,
+ isDirectory: false,
+ depth: 0,
+ operationOwner: { kind: 'local' }
+ })
+ ),
+ loading: false,
+ operationOwner: { kind: 'local' }
+ }
+}
+
+function cacheWithMeasuredChildren(paths: string[], onPathRead: () => void): DirCache {
+ const cache = cacheWithChildren(paths)
+ for (let index = 0; index < paths.length; index++) {
+ Object.defineProperty(cache.children[index]!, 'path', {
+ get: () => {
+ onPathRead()
+ return paths[index]
+ }
+ })
+ }
+ return cache
+}
+
+function processUpdate(args: {
+ root: string
+ absolutePath: string
+ isDirectory?: boolean
+ cache: Record
+}): ReturnType {
+ const refreshDir = vi.fn()
+ processFileExplorerFsPayload({
+ payload: {
+ worktreePath: args.root,
+ events: [{ kind: 'update', absolutePath: args.absolutePath, isDirectory: args.isDirectory }]
+ },
+ currentWorktreePath: args.root,
+ worktreeId: 'wt-1',
+ cache: args.cache,
+ expanded: new Set(),
+ setDirCache: vi.fn(),
+ setSelectedPath: vi.fn(),
+ refreshDir,
+ refreshTree: vi.fn()
+ })
+ return refreshDir
+}
+
+describe('processFileExplorerFsPayload update reconciliation', () => {
+ it('purges Windows descendants case-insensitively without folding remote POSIX paths', () => {
+ let cache: Record = {
+ 'C:\\Repo\\Old': cacheWithChildren([]),
+ 'c:\\repo\\OLD\\child': cacheWithChildren([]),
+ 'C:\\Repo\\Keep': cacheWithChildren([]),
+ '/srv/repo/Old': cacheWithChildren([]),
+ '/srv/repo/Old/child': cacheWithChildren([]),
+ '/srv/repo/old/keep': cacheWithChildren([])
+ }
+ type DirCacheUpdate = Parameters[0]>[0]
+ const setDirCache = (update: DirCacheUpdate): void => {
+ cache = typeof update === 'function' ? update(cache) : update
+ }
+
+ purgeDirCacheSubtrees(setDirCache, new Set(['C:\\repo\\old', '/srv/repo/Old']))
+
+ expect(Object.keys(cache)).toEqual(['C:\\Repo\\Keep', '/srv/repo/old/keep'])
+ })
+
+ it('refreshes a cached parent when Windows reports a new file as update', () => {
+ const root = 'C:\\Repo'
+ const refreshDir = processUpdate({
+ root,
+ absolutePath: 'c:\\repo\\new-file.txt',
+ isDirectory: false,
+ cache: { [root]: cacheWithChildren([`${root}\\existing.txt`]) }
+ })
+
+ expect(refreshDir).toHaveBeenCalledOnce()
+ expect(refreshDir).toHaveBeenCalledWith(root)
+ })
+
+ it('does not reread a directory for an existing file content update', () => {
+ const root = 'C:\\Repo'
+ const refreshDir = processUpdate({
+ root,
+ absolutePath: 'c:\\repo\\EXISTING.txt',
+ isDirectory: false,
+ cache: { [root]: cacheWithChildren([`${root}\\existing.txt`]) }
+ })
+
+ expect(refreshDir).not.toHaveBeenCalled()
+ })
+
+ it('indexes cached children once for a large update batch', () => {
+ const root = 'C:\\Repo'
+ const paths = Array.from({ length: 1_000 }, (_, index) => `${root}\\file-${index}.txt`)
+ let pathReads = 0
+ const refreshDir = vi.fn()
+
+ processFileExplorerFsPayload({
+ payload: {
+ worktreePath: root,
+ events: paths.map((absolutePath) => ({
+ kind: 'update' as const,
+ absolutePath: absolutePath.toLowerCase(),
+ isDirectory: false
+ }))
+ },
+ currentWorktreePath: root,
+ worktreeId: 'wt-1',
+ cache: { [root]: cacheWithMeasuredChildren(paths, () => pathReads++) },
+ expanded: new Set(),
+ setDirCache: vi.fn(),
+ setSelectedPath: vi.fn(),
+ refreshDir,
+ refreshTree: vi.fn()
+ })
+
+ expect(pathReads).toBe(paths.length)
+ expect(refreshDir).not.toHaveBeenCalled()
+ })
+
+ it('indexes cached directory keys once for a maximum update batch', () => {
+ const root = 'C:\\Repo'
+ const entries: Record = {
+ [root]: cacheWithChildren([])
+ }
+ for (let index = 0; index < 2_000; index++) {
+ entries[`${root}\\dir-${index}`] = cacheWithChildren([])
+ }
+ let cacheKeyReads = 0
+ const cache = new Proxy(entries, {
+ ownKeys(target) {
+ cacheKeyReads++
+ return Reflect.ownKeys(target)
+ }
+ })
+ const refreshDir = vi.fn()
+
+ processFileExplorerFsPayload({
+ payload: {
+ worktreePath: root,
+ events: Array.from({ length: 5_000 }, (_, index) => ({
+ kind: 'update' as const,
+ absolutePath: `c:\\repo\\new-${index}.txt`,
+ isDirectory: false
+ }))
+ },
+ currentWorktreePath: root,
+ worktreeId: 'wt-1',
+ cache,
+ expanded: new Set(),
+ setDirCache: vi.fn(),
+ setSelectedPath: vi.fn(),
+ refreshDir,
+ refreshTree: vi.fn()
+ })
+
+ expect(cacheKeyReads).toBe(1)
+ expect(refreshDir).toHaveBeenCalledOnce()
+ expect(refreshDir).toHaveBeenCalledWith(root)
+ })
+
+ it('does not rescan expanded directories for each refreshed cached parent', () => {
+ const root = 'C:\\Repo'
+ const cache: Record = { [root]: cacheWithChildren([]) }
+ const expandedPaths: string[] = []
+ for (let index = 0; index < 2_000; index++) {
+ cache[`${root}\\dir-${index}`] = cacheWithChildren([])
+ expandedPaths.push(`c:\\repo\\DIR-${index}`)
+ }
+ let expandedPathReads = 0
+ const expanded = new Set(expandedPaths)
+ const expandedIterator = expanded[Symbol.iterator].bind(expanded)
+ expanded[Symbol.iterator] = function* measuredExpandedIterator() {
+ for (const path of expandedIterator()) {
+ expandedPathReads++
+ yield path
+ }
+ return undefined
+ }
+ const refreshDir = vi.fn()
+
+ processFileExplorerFsPayload({
+ payload: {
+ worktreePath: root,
+ events: Array.from({ length: 5_000 }, (_, index) => ({
+ kind: 'create' as const,
+ absolutePath: `c:\\repo\\dir-${index % 2_000}\\new-${index}.txt`,
+ isDirectory: false
+ }))
+ },
+ currentWorktreePath: root,
+ worktreeId: 'wt-1',
+ cache,
+ expanded,
+ setDirCache: vi.fn(),
+ setSelectedPath: vi.fn(),
+ refreshDir,
+ refreshTree: vi.fn()
+ })
+
+ expect(expandedPathReads).toBe(0)
+ expect(refreshDir).toHaveBeenCalledTimes(2_000)
+ expect(new Set(refreshDir.mock.calls.map(([dirPath]) => dirPath)).size).toBe(2_000)
+ })
+
+ it('keeps POSIX child matching case-sensitive', () => {
+ const root = '/repo'
+ const refreshDir = processUpdate({
+ root,
+ absolutePath: '/repo/EXISTING.txt',
+ isDirectory: false,
+ cache: { [root]: cacheWithChildren(['/repo/existing.txt']) }
+ })
+
+ expect(refreshDir).toHaveBeenCalledWith(root)
+ })
+
+ it('refreshes an existing directory when the update identifies it as a directory', () => {
+ const root = '/repo'
+ const child = '/repo/src'
+ const refreshDir = processUpdate({
+ root,
+ absolutePath: child,
+ isDirectory: true,
+ cache: { [root]: cacheWithChildren([child]), [child]: cacheWithChildren([]) }
+ })
+
+ expect(refreshDir).toHaveBeenCalledOnce()
+ expect(refreshDir).toHaveBeenCalledWith(child)
+ })
+
+ it('deduplicates repeated remote update events for an absent child', () => {
+ const root = '/srv/workspace'
+ const refreshDir = vi.fn()
+ processFileExplorerFsPayload({
+ payload: {
+ worktreePath: root,
+ events: [
+ { kind: 'update', absolutePath: `${root}/new.txt`, isDirectory: false },
+ { kind: 'update', absolutePath: `${root}/new.txt`, isDirectory: false }
+ ]
+ },
+ currentWorktreePath: root,
+ worktreeId: 'folder::remote-1',
+ cache: { [root]: cacheWithChildren([]) },
+ expanded: new Set(),
+ setDirCache: vi.fn(),
+ setSelectedPath: vi.fn(),
+ refreshDir,
+ refreshTree: vi.fn()
+ })
+
+ expect(refreshDir).toHaveBeenCalledOnce()
+ expect(refreshDir).toHaveBeenCalledWith(root)
+ })
+
+ it('ignores an update payload from another workspace', () => {
+ const root = '/srv/workspace'
+ const refreshDir = vi.fn()
+ processFileExplorerFsPayload({
+ payload: {
+ worktreePath: '/srv/other',
+ events: [{ kind: 'update', absolutePath: '/srv/other/new.txt', isDirectory: false }]
+ },
+ currentWorktreePath: root,
+ worktreeId: 'folder::remote-1',
+ cache: { [root]: cacheWithChildren([]) },
+ expanded: new Set(),
+ setDirCache: vi.fn(),
+ setSelectedPath: vi.fn(),
+ refreshDir,
+ refreshTree: vi.fn()
+ })
+
+ expect(refreshDir).not.toHaveBeenCalled()
+ })
+
+ it('refreshes both parents for a synthetic cross-directory rename', () => {
+ const root = '/repo'
+ const sourceDir = `${root}/source`
+ const targetDir = `${root}/target`
+ const refreshDir = vi.fn()
+ const refreshTree = vi.fn()
+ const setSelectedPath = vi.fn()
+
+ processFileExplorerFsPayload({
+ payload: {
+ worktreePath: root,
+ events: [
+ {
+ kind: 'rename',
+ oldAbsolutePath: `${sourceDir}/old.txt`,
+ absolutePath: `${targetDir}/new.txt`,
+ isDirectory: false
+ }
+ ]
+ },
+ currentWorktreePath: root,
+ worktreeId: 'folder::remote-1',
+ cache: {
+ [root]: cacheWithChildren([sourceDir, targetDir]),
+ [sourceDir]: cacheWithChildren([`${sourceDir}/old.txt`]),
+ [targetDir]: cacheWithChildren([])
+ },
+ expanded: new Set([sourceDir, targetDir]),
+ setDirCache: vi.fn(),
+ setSelectedPath,
+ refreshDir,
+ refreshTree
+ })
+
+ expect(refreshDir).toHaveBeenCalledTimes(2)
+ expect(refreshDir).toHaveBeenCalledWith(sourceDir)
+ expect(refreshDir).toHaveBeenCalledWith(targetDir)
+ expect(refreshTree).not.toHaveBeenCalled()
+ expect(setSelectedPath.mock.calls[0]?.[0](`${sourceDir}/old.txt`)).toBeNull()
+ })
+
+ it('deduplicates subtree and selection state work for repeated renames', () => {
+ const root = '/repo'
+ const sourceDir = `${root}/source`
+ const targetDir = `${root}/target`
+ const oldDir = `${sourceDir}/old`
+ const newDir = `${targetDir}/new`
+ const rename = {
+ kind: 'rename' as const,
+ oldAbsolutePath: oldDir,
+ absolutePath: newDir,
+ isDirectory: true
+ }
+ const setDirCache = vi.fn()
+ const setSelectedPath = vi.fn()
+ const refreshDir = vi.fn()
+
+ processFileExplorerFsPayload({
+ payload: { worktreePath: root, events: [rename, rename] },
+ currentWorktreePath: root,
+ worktreeId: 'wt-1',
+ cache: {
+ [sourceDir]: cacheWithChildren([oldDir]),
+ [targetDir]: cacheWithChildren([newDir]),
+ [oldDir]: cacheWithChildren([]),
+ [newDir]: cacheWithChildren([])
+ },
+ expanded: new Set([sourceDir, targetDir]),
+ setDirCache,
+ setSelectedPath,
+ refreshDir,
+ refreshTree: vi.fn()
+ })
+
+ expect(setDirCache).toHaveBeenCalledOnce()
+ expect(setSelectedPath).toHaveBeenCalledOnce()
+ expect(refreshDir).toHaveBeenCalledTimes(2)
+ })
+
+ it('purges distinct cached directory renames with one bounded cache scan', () => {
+ const root = '/repo'
+ const worktreeId = 'watch-reconcile-perf'
+ const entries: Record = { [root]: cacheWithChildren([]) }
+ const expandedPaths: string[] = []
+ const events = Array.from({ length: 1_000 }, (_, index) => {
+ entries[`${root}/old-${index}`] = cacheWithChildren([])
+ entries[`${root}/new-${index}`] = cacheWithChildren([])
+ expandedPaths.push(`${root}/old-${index}`, `${root}/new-${index}`)
+ return {
+ kind: 'rename' as const,
+ oldAbsolutePath: `${root}/old-${index}`,
+ absolutePath: `${root}/new-${index}`,
+ isDirectory: true
+ }
+ })
+ let keyVisits = 0
+ const entryCount = Object.keys(entries).length
+ const measured = (value: Record): Record =>
+ new Proxy(value, {
+ getOwnPropertyDescriptor(target, property) {
+ keyVisits++
+ return Reflect.getOwnPropertyDescriptor(target, property)
+ }
+ })
+ let current = measured(entries)
+ type DirCacheUpdate = Parameters<
+ Parameters[0]['setDirCache']
+ >[0]
+ const setDirCache = vi.fn((update: DirCacheUpdate) => {
+ current = measured(typeof update === 'function' ? update(current) : update)
+ })
+ let expandedPathReads = 0
+ const expanded = new Set(expandedPaths)
+ const expandedIterator = expanded[Symbol.iterator].bind(expanded)
+ expanded[Symbol.iterator] = function* measuredExpandedIterator() {
+ for (const path of expandedIterator()) {
+ expandedPathReads++
+ yield path
+ }
+ return undefined
+ }
+ const previousExpandedDirs = useAppStore.getState().expandedDirs
+ let remainingExpanded: Set | undefined
+
+ try {
+ useAppStore.setState({
+ expandedDirs: { ...previousExpandedDirs, [worktreeId]: expanded }
+ })
+ processFileExplorerFsPayload({
+ payload: { worktreePath: root, events },
+ currentWorktreePath: root,
+ worktreeId,
+ cache: current,
+ expanded,
+ setDirCache,
+ setSelectedPath: vi.fn(),
+ refreshDir: vi.fn(),
+ refreshTree: vi.fn()
+ })
+ remainingExpanded = useAppStore.getState().expandedDirs[worktreeId]
+ } finally {
+ useAppStore.setState({ expandedDirs: previousExpandedDirs })
+ }
+
+ expect(setDirCache).toHaveBeenCalledOnce()
+ expect(keyVisits).toBe(entryCount * 2)
+ expect(expandedPathReads).toBe(expandedPaths.length)
+ expect(remainingExpanded).toEqual(new Set())
+ })
+})
diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.ts
new file mode 100644
index 00000000000..538657edb76
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.ts
@@ -0,0 +1,224 @@
+import type { Dispatch, SetStateAction } from 'react'
+import type { FsChangedPayload } from '../../../../shared/types'
+import type { DirCache } from './file-explorer-types'
+import {
+ isPathInsideOrEqual,
+ normalizeRuntimePathForComparison
+} from '../../../../shared/cross-platform-path'
+import {
+ purgeDirCacheSubtrees,
+ purgeExpandedDirsSubtrees,
+ clearStalePendingReveal
+} from './file-explorer-watcher-reconcile'
+import {
+ canonicalizeFileExplorerWatchPath,
+ createCachedDirPathIndex,
+ normalizeExplorerAbsolutePath,
+ parentDirForWatchPath,
+ resolveCachedDirPath
+} from './file-explorer-watch-path'
+
+export type ProcessFileExplorerFsPayloadArgs = {
+ payload: FsChangedPayload
+ currentWorktreePath: string
+ worktreeId: string
+ cache: Record
+ expanded: Set
+ setDirCache: Dispatch>>
+ setSelectedPath: Dispatch>
+ refreshDir: (dirPath: string) => void
+ refreshTree: () => void
+}
+
+function cachedDirectoryContainsPath(
+ cache: Record,
+ cachedDirPath: string,
+ childPath: string,
+ childPathIndexes: Map>
+): boolean {
+ let childPaths = childPathIndexes.get(cachedDirPath)
+ if (!childPaths) {
+ childPaths = new Set(
+ cache[cachedDirPath]?.children.map((child) =>
+ normalizeRuntimePathForComparison(child.path)
+ ) ?? []
+ )
+ childPathIndexes.set(cachedDirPath, childPaths)
+ }
+ return childPaths.has(normalizeRuntimePathForComparison(childPath))
+}
+
+export function processFileExplorerFsPayload(args: ProcessFileExplorerFsPayloadArgs): void {
+ const {
+ payload,
+ currentWorktreePath,
+ worktreeId,
+ cache,
+ setDirCache,
+ setSelectedPath,
+ refreshDir,
+ refreshTree
+ } = args
+ if (
+ normalizeRuntimePathForComparison(payload.worktreePath) !==
+ normalizeRuntimePathForComparison(currentWorktreePath)
+ ) {
+ return
+ }
+
+ const dirsToRefresh = new Set()
+ const childPathIndexes = new Map>()
+ const cachePathIndex = createCachedDirPathIndex(cache)
+ const cachedDirsToPurge = new Set()
+ const reconciledRenameSources = new Set()
+ let needsFullRefresh = false
+
+ const queueCachedDirPurge = (cachedDir: string | null): void => {
+ if (cachedDir) {
+ cachedDirsToPurge.add(cachedDir)
+ }
+ }
+
+ for (const evt of payload.events) {
+ if (evt.kind === 'overflow') {
+ needsFullRefresh = true
+ break
+ }
+
+ const normalizedPath = canonicalizeFileExplorerWatchPath(currentWorktreePath, evt.absolutePath)
+ if (!normalizedPath) {
+ continue
+ }
+
+ if (evt.kind === 'delete') {
+ // Why: watcher can't report isDirectory for deletes; a dirCache key means it was an expanded dir (design §4.4).
+ const cachedDir = resolveCachedDirPath(
+ cache,
+ normalizedPath,
+ currentWorktreePath,
+ cachePathIndex
+ )
+ const wasDirectory = cachedDir !== null
+
+ if (wasDirectory && cachedDir) {
+ queueCachedDirPurge(cachedDir)
+ }
+
+ clearStalePendingReveal(normalizedPath)
+
+ setSelectedPath((prev) => {
+ if (
+ prev &&
+ normalizeRuntimePathForComparison(prev) ===
+ normalizeRuntimePathForComparison(normalizedPath)
+ ) {
+ return null
+ }
+ if (prev && wasDirectory && isPathInsideOrEqual(normalizedPath, prev)) {
+ return null
+ }
+ return prev
+ })
+
+ const parent = parentDirForWatchPath(normalizedPath)
+ const cachedParent = resolveCachedDirPath(cache, parent, currentWorktreePath, cachePathIndex)
+ if (cachedParent) {
+ dirsToRefresh.add(cachedParent)
+ }
+ } else if (evt.kind === 'create' || evt.kind === 'rename') {
+ // Why: create and rename both change a parent's listing. Rename was
+ // previously deferred (#10264) so Explorer stayed stale until focus
+ // remounted the tree. Case-insensitive cache lookup covers Windows
+ // drive-letter / path casing drift between watcher and worktree path.
+ const parent = parentDirForWatchPath(normalizedPath)
+ const cachedParent = resolveCachedDirPath(cache, parent, currentWorktreePath, cachePathIndex)
+ if (cachedParent) {
+ dirsToRefresh.add(cachedParent)
+ }
+ if (evt.kind === 'rename') {
+ const oldPath = evt.oldAbsolutePath
+ ? canonicalizeFileExplorerWatchPath(currentWorktreePath, evt.oldAbsolutePath)
+ : null
+ const cachedOldDir = oldPath
+ ? resolveCachedDirPath(cache, oldPath, currentWorktreePath, cachePathIndex)
+ : null
+ if (oldPath) {
+ const oldParent = parentDirForWatchPath(oldPath)
+ const cachedOldParent = resolveCachedDirPath(
+ cache,
+ oldParent,
+ currentWorktreePath,
+ cachePathIndex
+ )
+ if (cachedOldParent) {
+ dirsToRefresh.add(cachedOldParent)
+ }
+
+ const sourceKey = normalizeRuntimePathForComparison(oldPath)
+ if (!reconciledRenameSources.has(sourceKey)) {
+ reconciledRenameSources.add(sourceKey)
+ clearStalePendingReveal(oldPath)
+ setSelectedPath((prev) => {
+ if (!prev) {
+ return prev
+ }
+ const selectedSource = normalizeRuntimePathForComparison(prev)
+ if (selectedSource === sourceKey) {
+ return null
+ }
+ return cachedOldDir && isPathInsideOrEqual(oldPath, prev) ? null : prev
+ })
+ }
+ }
+
+ const cachedNewDir = resolveCachedDirPath(
+ cache,
+ normalizedPath,
+ currentWorktreePath,
+ cachePathIndex
+ )
+ queueCachedDirPurge(cachedOldDir)
+ queueCachedDirPurge(cachedNewDir)
+ }
+ } else if (evt.kind === 'update') {
+ const cachedDir = resolveCachedDirPath(
+ cache,
+ normalizedPath,
+ currentWorktreePath,
+ cachePathIndex
+ )
+ if (evt.isDirectory === true && cachedDir) {
+ dirsToRefresh.add(cachedDir)
+ continue
+ }
+
+ const parent = parentDirForWatchPath(normalizedPath)
+ const cachedParent = resolveCachedDirPath(cache, parent, currentWorktreePath, cachePathIndex)
+ // Windows can classify a new file as update; existing file updates do not invalidate the tree.
+ if (
+ cachedParent &&
+ !dirsToRefresh.has(cachedParent) &&
+ !cachedDirectoryContainsPath(cache, cachedParent, normalizedPath, childPathIndexes)
+ ) {
+ dirsToRefresh.add(cachedParent)
+ }
+ }
+ }
+
+ purgeDirCacheSubtrees(setDirCache, cachedDirsToPurge)
+ purgeExpandedDirsSubtrees(worktreeId, cachedDirsToPurge)
+
+ if (needsFullRefresh) {
+ refreshTree()
+ return
+ }
+
+ const rootPath = normalizeExplorerAbsolutePath(currentWorktreePath)
+ for (const dirPath of dirsToRefresh) {
+ const isRoot =
+ normalizeRuntimePathForComparison(dirPath) === normalizeRuntimePathForComparison(rootPath)
+ if (isRoot || dirPath in cache) {
+ refreshDir(dirPath)
+ }
+ }
+}
diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts b/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts
index 517618f3fdd..0f151c54295 100644
--- a/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts
+++ b/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts
@@ -2,22 +2,56 @@ import type { Dispatch, SetStateAction } from 'react'
import type { DirCache } from './file-explorer-types'
import { normalizeAbsolutePath, isPathEqualOrDescendant } from './file-explorer-paths'
import { useAppStore } from '@/store'
+import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path'
// ── dirCache subtree purge ───────────────────────────────────────────
// Why: dirCache is component-local useState in useFileExplorerTree, not
// Zustand. This helper accepts the setter so it can be called from the
// watch effect without Zustand coupling. See design §5.2.
-export function purgeDirCacheSubtree(
+function createSubtreeMatcher(paths: ReadonlySet): (candidatePath: string) => boolean {
+ const normalizedRoots = new Set([...paths].map(normalizeRuntimePathForComparison))
+ return (candidatePath) => {
+ const candidate = normalizeRuntimePathForComparison(candidatePath)
+ if (normalizedRoots.has(candidate)) {
+ return true
+ }
+ if (candidate.startsWith('/') && normalizedRoots.has('/')) {
+ return true
+ }
+ for (
+ let index = candidate.indexOf('/');
+ index >= 0;
+ index = candidate.indexOf('/', index + 1)
+ ) {
+ if (
+ index === 2 &&
+ /^[a-z]:\//.test(candidate) &&
+ normalizedRoots.has(candidate.slice(0, 3))
+ ) {
+ return true
+ }
+ if (index > 0 && normalizedRoots.has(candidate.slice(0, index))) {
+ return true
+ }
+ }
+ return false
+ }
+}
+
+export function purgeDirCacheSubtrees(
setDirCache: Dispatch>>,
- deletedPath: string
+ deletedPaths: ReadonlySet
): void {
- const normalized = normalizeAbsolutePath(deletedPath)
+ if (deletedPaths.size === 0) {
+ return
+ }
+ const shouldPurge = createSubtreeMatcher(deletedPaths)
setDirCache((prev) => {
let changed = false
const next: Record = {}
for (const key of Object.keys(prev)) {
- if (isPathEqualOrDescendant(key, normalized)) {
+ if (shouldPurge(key)) {
changed = true
} else {
next[key] = prev[key]
@@ -32,8 +66,14 @@ export function purgeDirCacheSubtree(
// external directory delete, all expanded descendants of the deleted
// path must be removed so the tree doesn't show phantom folders.
-export function purgeExpandedDirsSubtree(worktreeId: string, deletedPath: string): void {
- const normalized = normalizeAbsolutePath(deletedPath)
+export function purgeExpandedDirsSubtrees(
+ worktreeId: string,
+ deletedPaths: ReadonlySet
+): void {
+ if (deletedPaths.size === 0) {
+ return
+ }
+ const shouldPurge = createSubtreeMatcher(deletedPaths)
useAppStore.setState((state) => {
const current = state.expandedDirs[worktreeId]
if (!current) {
@@ -43,7 +83,7 @@ export function purgeExpandedDirsSubtree(worktreeId: string, deletedPath: string
const next = new Set()
let changed = false
for (const dirPath of current) {
- if (isPathEqualOrDescendant(dirPath, normalized)) {
+ if (shouldPurge(dirPath)) {
changed = true
} else {
next.add(dirPath)
diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts
index bb08fbc9990..a5ee3f264ab 100644
--- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts
+++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts
@@ -1,10 +1,9 @@
import { describe, expect, it } from 'vitest'
-import type { FsChangedPayload } from '../../../../shared/types'
import {
canonicalizeFileExplorerWatchPath,
getFileExplorerWatchRuntimeEnvironmentId,
getExternalFileChangeRelativePath,
- payloadRequiresDeferredTreeRefresh
+ resolveCachedDirPath
} from './useFileExplorerWatch'
import type { AppState } from '@/store/types'
@@ -104,36 +103,23 @@ describe('canonicalizeFileExplorerWatchPath', () => {
})
})
-describe('payloadRequiresDeferredTreeRefresh', () => {
- function payload(events: FsChangedPayload['events'], worktreePath = '/repo'): FsChangedPayload {
- return { worktreePath, events }
- }
-
- it('does not require a full tree refresh for replayable deferred changes', () => {
- const changes = payload([
- { kind: 'create', absolutePath: '/repo/src/new.ts', isDirectory: false },
- { kind: 'update', absolutePath: '/repo/src', isDirectory: true },
- { kind: 'delete', absolutePath: '/repo/src/old.ts' }
- ])
-
- expect(payloadRequiresDeferredTreeRefresh(changes, '/repo')).toBe(false)
+describe('resolveCachedDirPath', () => {
+ it('returns the exact cache key when present', () => {
+ const cache = { '/repo/src': { children: [] } }
+ expect(resolveCachedDirPath(cache, '/repo/src')).toBe('/repo/src')
})
- it('requires a full tree refresh for unreplayable rename payloads in the current worktree', () => {
- const changes = payload([
- { kind: 'rename', absolutePath: '/repo/src/old.ts', isDirectory: false }
- ])
-
- expect(payloadRequiresDeferredTreeRefresh(changes, '/repo')).toBe(true)
+ it('matches Windows cache keys case-insensitively (#10264)', () => {
+ const cache = { 'C:\\Repo\\src': { children: [] } }
+ expect(resolveCachedDirPath(cache, 'c:\\repo\\src')).toBe('C:\\Repo\\src')
})
- it('ignores stale deferred rename payloads from a previous worktree', () => {
- const changes = payload(
- [{ kind: 'rename', absolutePath: '/other/src/old.ts', isDirectory: false }],
- '/other'
- )
+ it('falls back to the worktree root path when the root is not yet cached', () => {
+ expect(resolveCachedDirPath({}, 'C:\\Repo', 'C:\\Repo')).toBe('C:\\Repo')
+ })
- expect(payloadRequiresDeferredTreeRefresh(changes, '/repo')).toBe(false)
+ it('returns null when the directory is not cached and is not the worktree root', () => {
+ expect(resolveCachedDirPath({ '/repo': { children: [] } }, '/repo/src', '/repo')).toBeNull()
})
})
diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts
index 0d2cb4ad448..be089be1666 100644
--- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts
+++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts
@@ -2,23 +2,19 @@ import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
import type { FsChangedPayload } from '../../../../shared/types'
import type { DirCache, FileExplorerOperationOwner } from './file-explorer-types'
import type { InlineInput } from './FileExplorerRow'
-import { joinPath, normalizeRelativePath, dirname } from '@/lib/path'
-import {
- isPathInsideOrEqual,
- normalizeRuntimePathForComparison,
- relativePathInsideRoot
-} from '../../../../shared/cross-platform-path'
-import {
- purgeDirCacheSubtree,
- purgeExpandedDirsSubtree,
- clearStalePendingReveal
-} from './file-explorer-watcher-reconcile'
import { useAppStore } from '@/store'
import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client'
import {
getFileExplorerOperationOwnerFromState,
type FileExplorerOwnerState
} from './file-explorer-operation-owner'
+import { processFileExplorerFsPayload } from './file-explorer-watch-reconcile'
+
+export {
+ canonicalizeFileExplorerWatchPath,
+ getExternalFileChangeRelativePath,
+ resolveCachedDirPath
+} from './file-explorer-watch-path'
type FileExplorerWatchOwnerState = Pick<
FileExplorerOwnerState,
@@ -41,55 +37,6 @@ type UseFileExplorerWatchParams = {
operationOwner?: FileExplorerOperationOwner
}
-export function getExternalFileChangeRelativePath(
- worktreePath: string,
- absolutePath: string,
- isDirectory: boolean | undefined
-): string | null {
- if (isDirectory === true) {
- return null
- }
-
- const relativePath = relativePathInsideRoot(worktreePath, absolutePath)
- if (relativePath === null || relativePath === '') {
- return null
- }
-
- // Why: EditorPanel reloads tabs only from a worktree-relative path, not the watcher's absolute one; normalize or contents go stale.
- return normalizeRelativePath(relativePath)
-}
-
-export function canonicalizeFileExplorerWatchPath(
- worktreePath: string,
- absolutePath: string
-): string | null {
- const relativePath = relativePathInsideRoot(worktreePath, absolutePath)
- if (relativePath === null) {
- return null
- }
-
- const rootPath = normalizeExplorerAbsolutePath(worktreePath)
- return relativePath === '' ? rootPath : joinPath(rootPath, relativePath)
-}
-
-function normalizeExplorerAbsolutePath(path: string): string {
- return path === '/' || /^[A-Za-z]:[\\/]$/.test(path) ? path : path.replace(/[\\/]+$/, '')
-}
-
-export function payloadRequiresDeferredTreeRefresh(
- payload: FsChangedPayload,
- currentWorktreePath: string
-): boolean {
- if (
- normalizeRuntimePathForComparison(payload.worktreePath) !==
- normalizeRuntimePathForComparison(currentWorktreePath)
- ) {
- return false
- }
-
- return payload.events.some((evt) => evt.kind === 'rename')
-}
-
export function getFileExplorerWatchRuntimeEnvironmentId(
state: FileExplorerWatchOwnerState,
activeWorktreeId: string | null,
@@ -181,104 +128,25 @@ export function useFileExplorerWatch({
const currentWorktreePath = worktreePath
function processPayload(payload: FsChangedPayload): void {
- // Why: stale batched events from the old worktree can arrive after a switch and corrupt dirCache (design §3).
- if (
- normalizeRuntimePathForComparison(payload.worktreePath) !==
- normalizeRuntimePathForComparison(currentWorktreePath)
- ) {
- return
- }
-
const wtId = worktreeIdRef.current
if (!wtId) {
return
}
-
- const cache = dirCacheRef.current
- const exp = expandedRef.current
-
- // Collect directories that need refreshing
- const dirsToRefresh = new Set()
- let needsFullRefresh = false
-
- for (const evt of payload.events) {
- if (evt.kind === 'overflow') {
- needsFullRefresh = true
- break
- }
-
- const normalizedPath = canonicalizeFileExplorerWatchPath(
- currentWorktreePath,
- evt.absolutePath
- )
- if (!normalizedPath) {
- continue
- }
-
- if (evt.kind === 'delete') {
- // Why: watcher can't report isDirectory for deletes; a dirCache key means it was an expanded dir (design §4.4).
- const wasDirectory = normalizedPath in cache
-
- if (wasDirectory) {
- purgeDirCacheSubtree(setDirCache, normalizedPath)
- purgeExpandedDirsSubtree(wtId, normalizedPath)
- }
-
- // Clear pendingExplorerReveal if it targets the deleted path or a descendant.
- clearStalePendingReveal(normalizedPath)
-
- // Clear selectedPath if it points into the deleted subtree
- setSelectedPath((prev) => {
- if (
- prev &&
- normalizeRuntimePathForComparison(prev) ===
- normalizeRuntimePathForComparison(normalizedPath)
- ) {
- return null
- }
- if (prev && wasDirectory && isPathInsideOrEqual(normalizedPath, prev)) {
- return null
- }
- return prev
- })
-
- // Invalidate the parent directory
- const parent = normalizeExplorerAbsolutePath(dirname(normalizedPath))
- if (parent in cache) {
- dirsToRefresh.add(parent)
- }
- } else if (evt.kind === 'create') {
- // Invalidate the parent directory
- const parent = normalizeExplorerAbsolutePath(dirname(normalizedPath))
- if (parent in cache) {
- dirsToRefresh.add(parent)
- }
- } else if (evt.kind === 'update') {
- // Why: only directory updates invalidate; file-content updates are ignored in v1 (design §6.1).
- if (evt.isDirectory === true) {
- if (normalizedPath in cache) {
- dirsToRefresh.add(normalizedPath)
- }
- }
- }
- // 'rename' is deferred to v2 (design §5.3)
- }
-
- if (needsFullRefresh) {
- void refreshTreeRef.current()
- return
- }
-
- // Only refresh dirs already loaded and reachable (root, expanded, or cached).
- for (const dirPath of dirsToRefresh) {
- if (
- dirPath === normalizeExplorerAbsolutePath(currentWorktreePath) ||
- exp.has(dirPath) ||
- dirPath in dirCacheRef.current
- ) {
+ processFileExplorerFsPayload({
+ payload,
+ currentWorktreePath,
+ worktreeId: wtId,
+ cache: dirCacheRef.current,
+ expanded: expandedRef.current,
+ setDirCache,
+ setSelectedPath,
+ refreshDir: (dirPath) => {
void refreshDirRef.current(dirPath)
+ },
+ refreshTree: () => {
+ void refreshTreeRef.current()
}
- }
+ })
}
// Why: expose processPayload to the flush effect so it can replay deferred payloads without re-subscribing.
@@ -353,19 +221,12 @@ export function useFileExplorerWatch({
deferredRef.current.length > 0
) {
const deferred = deferredRef.current.splice(0)
- const requiresFullRefresh = worktreePath
- ? deferred.some((payload) => payloadRequiresDeferredTreeRefresh(payload, worktreePath))
- : false
// Why: replay deferred payloads so the tree cache reconciles to disk after inline input or drag ends (design §6.2).
if (processPayloadRef.current) {
for (const payload of deferred) {
processPayloadRef.current(payload)
}
}
- // Why: create/delete/update already replayed above; only kinds this reconciler can't apply (rename) pay the full-tree refresh.
- if (requiresFullRefresh) {
- void refreshTreeRef.current()
- }
}
- }, [inlineInput, dragSourcePath, isNativeDragOver, worktreePath])
+ }, [inlineInput, dragSourcePath, isNativeDragOver])
}
diff --git a/tests/e2e/file-explorer-watch-refresh.spec.ts b/tests/e2e/file-explorer-watch-refresh.spec.ts
new file mode 100644
index 00000000000..d8a1bc1e4e7
--- /dev/null
+++ b/tests/e2e/file-explorer-watch-refresh.spec.ts
@@ -0,0 +1,59 @@
+import { renameSync, rmSync, writeFileSync } from 'node:fs'
+import path from 'node:path'
+import { test, expect } from './helpers/orca-app'
+import { openFileExplorer } from './helpers/file-explorer'
+import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
+
+test('refreshes the visible tree after external Windows file changes', async ({ orcaPage }) => {
+ await waitForSessionReady(orcaPage)
+ await waitForActiveWorktree(orcaPage)
+ await orcaPage.evaluate(() => window.__store?.getState().setRightSidebarOpen(false))
+ await expect
+ .poll(() => orcaPage.evaluate(() => window.__store?.getState().rightSidebarOpen))
+ .toBe(false)
+ await openFileExplorer(orcaPage)
+
+ const worktreePath = await orcaPage.evaluate(() => {
+ const state = window.__store?.getState()
+ const worktreeId = state?.activeWorktreeId
+ if (!state || !worktreeId) {
+ throw new Error('active worktree unavailable')
+ }
+ const worktree = Object.values(state.worktreesByRepo)
+ .flat()
+ .find((candidate) => candidate.id === worktreeId)
+ if (!worktree) {
+ throw new Error('active worktree path unavailable')
+ }
+ return worktree.path
+ })
+
+ const originalName = 'watch-refresh-case.txt'
+ const renamedName = 'WATCH-REFRESH-CASE.txt'
+ const originalPath = path.join(worktreePath, originalName)
+ const renamedPath = path.join(worktreePath, renamedName)
+ const row = (name: string) =>
+ orcaPage
+ .locator('[data-file-explorer-row]')
+ .filter({ hasText: new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`) })
+
+ rmSync(originalPath, { force: true })
+ rmSync(renamedPath, { force: true })
+ try {
+ await expect(row('README.md')).toBeVisible({ timeout: 10_000 })
+ await orcaPage.waitForTimeout(2_000)
+
+ writeFileSync(originalPath, 'created outside Orca\n')
+ await expect(row(originalName)).toBeVisible({ timeout: 10_000 })
+
+ renameSync(originalPath, renamedPath)
+ await expect(row(renamedName)).toBeVisible({ timeout: 10_000 })
+ await expect(row(originalName)).toHaveCount(0, { timeout: 10_000 })
+
+ rmSync(renamedPath)
+ await expect(row(renamedName)).toHaveCount(0, { timeout: 10_000 })
+ } finally {
+ rmSync(originalPath, { force: true })
+ rmSync(renamedPath, { force: true })
+ }
+})
From aeeae53b1f12cd692522b1f3d629bd7c6d50e8ac Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Tue, 28 Jul 2026 21:34:55 -0700
Subject: [PATCH 13/67] fix(build): stop `pnpm -r` from crawling the mobile
workspace (#11291)
Co-authored-by: Orca
---
pnpm-workspace.yaml | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 pnpm-workspace.yaml
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 00000000000..52304c4dedb
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,5 @@
+# The desktop app is a single-project install. `mobile/` is a separate workspace
+# with its own pnpm-workspace.yaml and lockfile, so keep it out of the root
+# package graph. Without this file `pnpm -r` auto-discovers mobile/ and its
+# nested package, and the root's patchedDependencies fail as ERR_PNPM_UNUSED_PATCH.
+packages: []
From 1df8aa56051cbe1ca08c3c0133abdd3b503cbd8e Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 28 Jul 2026 21:57:41 -0700
Subject: [PATCH 14/67] fix(dashboard): give the agent preview terminal a real
pane's keyboard (#11015)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(dashboard): give the agent preview terminal a real pane's keyboard
The dashboard's preview terminal is a bare xterm, not a pane, so it never
ran `resolveTerminalShortcutAction` — its only custom key handler covered
copy/paste and IME. Ctrl+Backspace therefore fell through to xterm's default
`\x08`, which readline binds to backward-delete-char: one character instead
of a word.
Route the preview's keys through the pane's own shortcut policy, so word and
line kills, Option chords, modified Enter, and scrollback chords encode
identically. Pane-scoped verdicts (splits, search, focus) are swallowed rather
than passed to xterm, which would send e.g. Ctrl+Shift+D as a bare Ctrl+D.
The policy needs three things the preview could not see:
- kitty-protocol flags — mirrored locally from the same PTY output stream
- the PTY's execution host — bytes follow the host, not the client OS, so a
new `DashboardCard.terminalInput` profile is derived in the main renderer
(the only one holding the store) and relayed to the pop-out
- host terminal options — the ConPTY backend and the kitty withhold now apply
Also brings the emulator itself up to a pane's: Orca's Unicode 11 width shim
(replayed CJK/emoji/ZWJ laid out wrong without it), Windows Ctrl+Alt chord
repair, user font/cursor/line-height/word-separator/sensitivity settings,
ligatures, the TUI wheel multiplier, lazy Arabic shaping, clickable links, and
the IME candidate anchor — extracted from pane-lifecycle so both surfaces share
one implementation.
The in-window drawer built its snapshot from a slice subset, which would have
degraded the new profile to client-OS defaults there; it now reads the full
store non-reactively.
* fix(dashboard): enumerate pane-scoped chords instead of a default case
The switch-exhaustiveness gate rejects a `default` over the shortcut-action
union — it would let a newly added action be swallowed silently instead of
forcing a decision at the preview's boundary.
* fix(dashboard): preserve native shortcuts and PTY host routing
* chore: keep merge scope limited to dashboard
* perf(dashboard): avoid full-store copies for terminal profiles
* fix(dashboard): validate terminal input profiles at IPC boundary
* fix(dashboard): sync preview terminal refs on commit, not during render
react-compiler rejects ref writes in the render pass; every reader is an event handler or a post-await continuation, so a commit-phase sync is equivalent.
* test(dashboard): cover the three seams that relay the host-input profile
Reverting any of them left every suite green: the dialog's terminalInput prop
(the only reader of DashboardCard.terminalInput), the drawer's hand-threaded
store slices, and the pop-out's republish triggers. Each new assertion was
mutation-checked against its source line.
* fix(dashboard): follow the WSL host for a preview terminal's byte routing
The card resolver handed resolveTerminalInputHostPlatform a transport with no
getLocalSessionMetadata, so a WSL pty on a Windows client resolved to win32
while its own pane resolves linux — Shift+Enter would then encode CSI-u where
the pane sends alt-enter. Mirror the pane transport's own gate.
* fix(dashboard): republish on every slice the host-input profile resolves from
The compare set covered 4 of the ~11 slices that decide a card's execution
host, so a change to the rest (folder workspaces, project groups, the runtime
catalog, detected worktrees) never triggered a publish. On a quiet board there
is no later publish to heal from, and the pop-out — which cannot re-derive the
profile — keeps encoding bytes for the host the pty used to run on.
* fix(dashboard): sync preview terminal refs on layout, not on a passive effect
xterm's keydown is a native listener, so React never flushes a passive effect
before it. A just-relayed host profile could therefore miss the next keystroke.
* test(dashboard): pin the preview's replay-vs-live kitty scan
The existing A/B passed either way: a lone CSI > u sets the same flags through
scan and scanReplay. Redeliver the push across a snapshot and its replay so
stack semantics would leave the TUI's single pop on a stale frame.
* fix(dashboard): rebuild the drawer's snapshot on every host-input slice
The in-window drawer read ~12 host slices through getState() while subscribing
to none of them, on the premise that agent activity drives the next rebuild. A
quiet board has no such rebuild: an SSH handshake completing with every agent
idle leaves the preview terminal encoding bytes for the pre-connect host, and
agentStatusEpoch only ticks on live status changes so it never heals. Watch the
same set useDashboardPopoutBridge republishes on — each writer bails out when
nothing changed, so the added deps are far quieter than agentStatusByPaneKey,
which already rebuilds this memo on every status ping.
The existing coverage mounted a second hook, which always recomputes; the new
test re-renders the same hook after changing only sshConnectionStates.
* fix(dashboard): key the preview by the user's terminal shortcut policy
The preview passed 11 of the 12 inputs the pane's policy takes and let the
12th default to orca-first. Under terminal-first a remapped tab.close chord is
meant to yield to the shell — Ctrl+W is a word-kill there — but the preview
kept claiming it as a pane close and swallowed the bytes.
* test(dashboard): pin the host-input profile to card snapshots only
The count path main added in #11042 renders no cards, so it must not pay a
per-pty host resolution on every agent-status tick. Both assertions run against
a card that does have a live pty, so only the gate keeps the profile off.
* refactor(dashboard): extract the board's client-host read
The merge of main's label bounding pushed build-dashboard-snapshot.ts to
302 lines. The client's own platform facts are a distinct concept from the
pty host each card keys against, so they move out rather than earn a
max-lines bypass.
---
.../ipc/dashboard-payload-validation.test.ts | 51 ++++
src/main/ipc/dashboard-payload-validation.ts | 36 ++-
.../AgentTerminalDialog.test.tsx | 88 +++++++
.../dashboard-popout/AgentTerminalDialog.tsx | 2 +-
.../AgentTerminalPreview.test.tsx | 189 +++++++++++++-
.../dashboard-popout/AgentTerminalPreview.tsx | 245 ++++++++++--------
.../preview-clipboard-shortcuts.ts | 70 -----
.../preview-terminal-compatibility.ts | 51 ++++
.../preview-terminal-ime-bridge.ts | 40 +++
.../preview-terminal-key-handler.ts | 182 +++++++++++++
.../preview-terminal-ligatures.ts | 43 +++
.../preview-terminal-links.ts | 26 ++
.../preview-terminal-options.test.ts | 92 +++++++
.../preview-terminal-options.ts | 83 ++++++
.../preview-terminal-paste.ts | 63 +++++
.../preview-terminal-shortcuts.test.ts | 148 +++++++++++
.../preview-terminal-shortcuts.ts | 58 +++++
.../build-dashboard-snapshot.test.ts | 14 +
.../dashboard/build-dashboard-snapshot.ts | 29 ++-
.../dashboard-card-terminal-input.test.ts | 144 ++++++++++
.../dashboard-card-terminal-input.ts | 132 ++++++++++
.../dashboard/dashboard-client-host.ts | 32 +++
.../useDashboardPopoutBridge.test.tsx | 41 ++-
.../dashboard/useDashboardPopoutBridge.ts | 22 +-
.../useLiveDashboardSnapshot.test.ts | 38 +++
.../dashboard/useLiveDashboardSnapshot.ts | 51 +++-
.../src/lib/pane-manager/pane-lifecycle.ts | 59 +----
.../terminal-ime-candidate-anchor.ts | 67 +++++
.../pane-manager/windows-pty-compatibility.ts | 13 +-
src/shared/dashboard-snapshot.ts | 23 ++
30 files changed, 1883 insertions(+), 249 deletions(-)
create mode 100644 src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx
delete mode 100644 src/renderer/src/components/dashboard-popout/preview-clipboard-shortcuts.ts
create mode 100644 src/renderer/src/components/dashboard-popout/preview-terminal-compatibility.ts
create mode 100644 src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge.ts
create mode 100644 src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts
create mode 100644 src/renderer/src/components/dashboard-popout/preview-terminal-ligatures.ts
create mode 100644 src/renderer/src/components/dashboard-popout/preview-terminal-links.ts
create mode 100644 src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts
create mode 100644 src/renderer/src/components/dashboard-popout/preview-terminal-options.ts
create mode 100644 src/renderer/src/components/dashboard-popout/preview-terminal-paste.ts
create mode 100644 src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts
create mode 100644 src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts
create mode 100644 src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts
create mode 100644 src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts
create mode 100644 src/renderer/src/components/dashboard/dashboard-client-host.ts
create mode 100644 src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.ts
diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts
index 7baed3adbb8..954723a6182 100644
--- a/src/main/ipc/dashboard-payload-validation.test.ts
+++ b/src/main/ipc/dashboard-payload-validation.test.ts
@@ -177,6 +177,57 @@ describe('dashboard payload validation', () => {
).toBe(false)
})
+ it('validates the preview terminal input profile', () => {
+ const terminalInput = {
+ hostPlatform: 'win32',
+ localWindowsConpty: true,
+ osRelease: '10.0.22631',
+ windowsShiftEnterEncoding: 'alt-enter',
+ kittyKeyboardAdvertised: false
+ }
+ expect(
+ isDashboardSnapshot({
+ ...SNAPSHOT,
+ cards: [{ ...SNAPSHOT.cards[0], terminalInput }]
+ })
+ ).toBe(true)
+ for (const invalid of [
+ { ...terminalInput, hostPlatform: 'windows' },
+ { ...terminalInput, localWindowsConpty: 'true' },
+ { ...terminalInput, osRelease: 'x'.repeat(1_025) },
+ { ...terminalInput, windowsShiftEnterEncoding: 'enter' },
+ { ...terminalInput, kittyKeyboardAdvertised: 1 }
+ ]) {
+ expect(
+ isDashboardSnapshot({
+ ...SNAPSHOT,
+ cards: [{ ...SNAPSHOT.cards[0], terminalInput: invalid }]
+ })
+ ).toBe(false)
+ }
+ })
+
+ // Why: terminalInput is per-card, so a host profile this validator does not
+ // know must cost that preview its card, never the whole board.
+ it('drops only the card whose terminal input profile is unusable', () => {
+ const good = SNAPSHOT.cards[0]
+ const bad = {
+ ...good,
+ paneKey: 'tab-2:leaf-2',
+ terminalInput: {
+ hostPlatform: 'plan9',
+ localWindowsConpty: false,
+ windowsShiftEnterEncoding: 'csi-u',
+ kittyKeyboardAdvertised: true
+ }
+ }
+
+ const admitted = admitDashboardSnapshot({ ...SNAPSHOT, cards: [good, bad] })
+
+ expect(admitted?.droppedCardCount).toBe(1)
+ expect(admitted?.snapshot.cards.map((card) => card.paneKey)).toEqual(['tab-1:leaf-1'])
+ })
+
// Why: the pop-out replays the last accepted snapshot, so rejecting the whole
// board over one card froze every other agent's status until it was renamed.
describe('admitDashboardSnapshot', () => {
diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts
index 00c62b10554..0dfb80648d9 100644
--- a/src/main/ipc/dashboard-payload-validation.ts
+++ b/src/main/ipc/dashboard-payload-validation.ts
@@ -29,6 +29,20 @@ const MAX_LABEL_LENGTH = DASHBOARD_MAX_LABEL_LENGTH
const DASHBOARD_BUCKETS = new Set(['attention', 'working', 'done', 'idle'])
const DASHBOARD_DOT_STATES = new Set(['working', 'blocked', 'waiting', 'done', 'idle'])
const DASHBOARD_REVIEW_STATES = new Set(['open', 'closed', 'merged', 'draft'])
+const DASHBOARD_HOST_PLATFORMS = new Set([
+ 'aix',
+ 'android',
+ 'cygwin',
+ 'darwin',
+ 'freebsd',
+ 'haiku',
+ 'linux',
+ 'netbsd',
+ 'openbsd',
+ 'sunos',
+ 'win32'
+])
+const WINDOWS_SHIFT_ENTER_ENCODINGS = new Set(['alt-enter', 'csi-u'])
function isBoundedString(value: unknown, maxLength: number, allowEmpty = false): value is string {
return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0)
@@ -263,6 +277,26 @@ function isDashboardCard(value: unknown): boolean {
isFiniteNumber(card.stateChangedAt) &&
typeof card.unseen === 'boolean' &&
isOptionalBoundedString(card.askSummary, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH) &&
- isOptionalBoundedString(card.conversationName, MAX_LABEL_LENGTH)
+ isOptionalBoundedString(card.conversationName, MAX_LABEL_LENGTH) &&
+ isDashboardTerminalInput(card.terminalInput)
+ )
+}
+
+function isDashboardTerminalInput(value: unknown): boolean {
+ if (value === undefined) {
+ return true
+ }
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return false
+ }
+ const input = value as Record
+ return (
+ typeof input.hostPlatform === 'string' &&
+ DASHBOARD_HOST_PLATFORMS.has(input.hostPlatform) &&
+ typeof input.localWindowsConpty === 'boolean' &&
+ isOptionalBoundedString(input.osRelease, MAX_LABEL_LENGTH) &&
+ typeof input.windowsShiftEnterEncoding === 'string' &&
+ WINDOWS_SHIFT_ENTER_ENCODINGS.has(input.windowsShiftEnterEncoding) &&
+ typeof input.kittyKeyboardAdvertised === 'boolean'
)
}
diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx
new file mode 100644
index 00000000000..6610efe2605
--- /dev/null
+++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx
@@ -0,0 +1,88 @@
+// @vitest-environment happy-dom
+
+import '@testing-library/jest-dom/vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, render, screen } from '@testing-library/react'
+import type {
+ DashboardCard,
+ DashboardCardTerminalInput
+} from '../../../../shared/dashboard-snapshot'
+import { AgentTerminalDialog } from './AgentTerminalDialog'
+
+// Stub the preview so the assertion is on the props the dialog hands it, with
+// no xterm / IPC machinery in the way.
+vi.mock('./AgentTerminalPreview', () => ({
+ AgentTerminalPreview: ({
+ ptyId,
+ terminalInput
+ }: {
+ ptyId: string
+ terminalInput?: DashboardCardTerminalInput | null
+ }) => (
+
+ )
+}))
+
+const TERMINAL_INPUT: DashboardCardTerminalInput = {
+ hostPlatform: 'win32',
+ localWindowsConpty: true,
+ osRelease: '10.0.22631',
+ windowsShiftEnterEncoding: 'csi-u',
+ kittyKeyboardAdvertised: false
+}
+
+function card(overrides: Partial = {}): DashboardCard {
+ return {
+ paneKey: 'tab1:leaf1',
+ ptyId: 'pty-1',
+ agentType: 'claude',
+ bucket: 'working',
+ dotState: 'working',
+ task: 'task',
+ repoId: 'r1',
+ worktreeId: 'w1',
+ tabId: 'tab1',
+ leafId: 'leaf1',
+ repoName: 'Repo',
+ worktreeName: 'wt',
+ startedAt: 0,
+ finishedAt: null,
+ stateChangedAt: 0,
+ unseen: false,
+ ...overrides
+ }
+}
+
+afterEach(() => {
+ cleanup()
+})
+
+// Why: this is the only seam carrying the relayed host profile into the
+// emulator. Dropping the prop degrades every preview to client-OS byte routing
+// silently — nothing else in the app reads DashboardCard.terminalInput.
+describe('AgentTerminalDialog', () => {
+ it("hands the card's relayed host-input profile to the preview terminal", () => {
+ render(
+ {}}
+ onReveal={() => {}}
+ />
+ )
+
+ expect(screen.getByTestId('preview')).toHaveAttribute(
+ 'data-terminal-input',
+ JSON.stringify(TERMINAL_INPUT)
+ )
+ })
+
+ it('passes null when the card carries no profile, so the preview routes by client OS', () => {
+ render( {}} onReveal={() => {}} />)
+
+ expect(screen.getByTestId('preview')).toHaveAttribute('data-terminal-input', 'null')
+ })
+})
diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx
index 23a5e5b1d0c..ead43090820 100644
--- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx
+++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx
@@ -96,7 +96,7 @@ export function AgentTerminalDialog({
{card.ptyId ? (
-
+
) : (
{translate(
diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx
index 575e906dff2..01ac36f41ed 100644
--- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx
+++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx
@@ -22,6 +22,8 @@ const terminalHarness = vi.hoisted(() => ({
reset: ReturnType
paste: ReturnType
input: ReturnType
+ scrollToTop: ReturnType
+ scrollToBottom: ReturnType
modes: { bracketedPasteMode: boolean }
selectionText: string
customKeyHandler: ((event: KeyboardEvent) => boolean) | null
@@ -31,6 +33,10 @@ const terminalHarness = vi.hoisted(() => ({
}))
const platformState = vi.hoisted(() => ({ value: 'linux' }))
+const storeState = vi.hoisted(() => ({
+ settings: null,
+ keybindings: {} as Record
+}))
const imeHarness = vi.hoisted(() => ({
forwarders: [] as {
@@ -72,6 +78,11 @@ vi.mock('@xterm/xterm', () => ({
this.onDataListener?.(data)
})
element = document.createElement('div')
+ unicode = { activeVersion: '6', versions: ['6', '11'], register: vi.fn() }
+ loadAddon = vi.fn()
+ attachCustomWheelEventHandler = vi.fn()
+ scrollToTop = vi.fn()
+ scrollToBottom = vi.fn()
getSelection = vi.fn(() => this.selectionText)
attachCustomKeyEventHandler = vi.fn((handler: (event: KeyboardEvent) => boolean) => {
this.customKeyHandler = handler
@@ -86,7 +97,8 @@ vi.mock('@xterm/xterm', () => ({
}
}
}))
-vi.mock('@/lib/pane-manager/pane-terminal-options', () => ({
+vi.mock(import('@/lib/pane-manager/pane-terminal-options'), async (importOriginal) => ({
+ ...(await importOriginal()),
buildDefaultTerminalOptions: () => ({})
}))
vi.mock('@/components/terminal-pane/terminal-user-input-signal', () => ({
@@ -126,9 +138,8 @@ vi.mock('@/components/terminal-pane/terminal-ime-input-source', () => ({
}
}))
vi.mock('@/store', () => {
- const state = { settings: null, keybindings: {} }
- const useAppStore = (selector: (s: typeof state) => unknown): unknown => selector(state)
- useAppStore.getState = (): typeof state => state
+ const useAppStore = (selector: (s: typeof storeState) => unknown): unknown => selector(storeState)
+ useAppStore.getState = (): typeof storeState => storeState
return { useAppStore }
})
@@ -149,6 +160,7 @@ describe('AgentTerminalPreview', () => {
terminalHarness.instances.length = 0
terminalHarness.userInputListener = null
platformState.value = 'linux'
+ storeState.keybindings = {}
imeHarness.forwarders.length = 0
imeHarness.trackers.length = 0
imeHarness.claimResult = false
@@ -346,6 +358,175 @@ describe('AgentTerminalPreview', () => {
expect(terminalHarness.instances[0]!.paste).not.toHaveBeenCalled()
})
+ it('sends the word-kill byte on Ctrl+Backspace and blocks xterm handling', async () => {
+ render()
+ await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
+ const terminal = terminalHarness.instances[0]!
+ await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
+
+ const keydown = new KeyboardEvent('keydown', {
+ key: 'Backspace',
+ code: 'Backspace',
+ ctrlKey: true,
+ cancelable: true
+ })
+ const handled = terminal.customKeyHandler!(keydown)
+
+ expect(handled).toBe(false)
+ expect(keydown.defaultPrevented).toBe(true)
+ expect(terminal.input).toHaveBeenCalledWith('\x17')
+ await waitFor(() => expect(input).toHaveBeenCalledWith('pty-1', '\x17'))
+ })
+
+ it('swallows a pane-scoped chord instead of leaking its control byte to the agent', async () => {
+ render()
+ await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
+ const terminal = terminalHarness.instances[0]!
+ await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
+
+ // Ctrl+Shift+D splits a pane on Linux; xterm would otherwise send Ctrl+D.
+ const keydown = new KeyboardEvent('keydown', {
+ key: 'D',
+ code: 'KeyD',
+ ctrlKey: true,
+ shiftKey: true,
+ cancelable: true
+ })
+ const handled = terminal.customKeyHandler!(keydown)
+
+ expect(handled).toBe(false)
+ expect(keydown.defaultPrevented).toBe(true)
+ expect(terminal.input).not.toHaveBeenCalled()
+ expect(input).not.toHaveBeenCalled()
+ })
+
+ it('keeps a native input-source chord from inserting text into the preview', async () => {
+ storeState.keybindings = { 'terminal.switchInputSource': ['Shift+Space'] }
+ const view = render()
+ await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
+ const terminal = terminalHarness.instances[0]!
+ await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
+
+ const keydown = new KeyboardEvent('keydown', {
+ key: ' ',
+ code: 'Space',
+ shiftKey: true,
+ bubbles: true,
+ cancelable: true
+ })
+ expect(terminal.customKeyHandler!(keydown)).toBe(false)
+ expect(keydown.defaultPrevented).toBe(false)
+
+ const keypress = new KeyboardEvent('keypress', {
+ key: ' ',
+ bubbles: true,
+ cancelable: true
+ })
+ window.dispatchEvent(keypress)
+ expect(keypress.defaultPrevented).toBe(true)
+
+ const beforeInput = new InputEvent('beforeinput', {
+ data: ' ',
+ inputType: 'insertText',
+ bubbles: true,
+ cancelable: true
+ })
+ window.dispatchEvent(beforeInput)
+ expect(beforeInput.defaultPrevented).toBe(true)
+
+ window.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', code: 'Space', bubbles: true }))
+ const unarmedBeforeInput = new InputEvent('beforeinput', {
+ data: ' ',
+ inputType: 'insertText',
+ bubbles: true,
+ cancelable: true
+ })
+ window.dispatchEvent(unarmedBeforeInput)
+ expect(unarmedBeforeInput.defaultPrevented).toBe(false)
+ expect(terminal.input).not.toHaveBeenCalled()
+ expect(input).not.toHaveBeenCalled()
+
+ view.unmount()
+ })
+
+ it('defers Option chords to xterm once the TUI negotiates kitty keyboard mode', async () => {
+ render()
+ await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
+ const terminal = terminalHarness.instances[0]!
+ await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
+
+ const altBackspace = (): KeyboardEvent =>
+ new KeyboardEvent('keydown', { key: 'Backspace', code: 'Backspace', altKey: true })
+ expect(terminal.customKeyHandler!(altBackspace())).toBe(false)
+ expect(terminal.input).toHaveBeenCalledWith('\x1b\x7f')
+
+ // The agent's TUI pushes kitty flags (CSI > 1 u) on the live stream.
+ act(() => {
+ emitData?.({ type: 'data', ptyId: 'pty-1', data: '\x1b[>1u', bytes: 5 })
+ })
+ terminal.input.mockClear()
+
+ expect(terminal.customKeyHandler!(altBackspace())).toBe(true)
+ expect(terminal.input).not.toHaveBeenCalled()
+ })
+
+ // Why: a snapshot carries the TUI's one-time kitty push and the post-snapshot
+ // replay redelivers it. Applying replays with stack semantics would leave the
+ // TUI's single pop on a stale frame, so a plain shell keeps getting
+ // kitty-encoded Option chords.
+ it('does not let a redelivered kitty push outlive the TUI pop', async () => {
+ connect.mockResolvedValueOnce({
+ snapshot: { data: '\x1b[>1u', cols: 80, rows: 24, seq: 1 },
+ replay: ['\x1b[>1u']
+ })
+ render()
+ await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
+ const terminal = terminalHarness.instances[0]!
+ await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
+
+ const altBackspace = (): KeyboardEvent =>
+ new KeyboardEvent('keydown', { key: 'Backspace', code: 'Backspace', altKey: true })
+ expect(terminal.customKeyHandler!(altBackspace())).toBe(true)
+
+ // The TUI exits and pops once on the live stream.
+ act(() => {
+ emitData?.({ type: 'data', ptyId: 'pty-1', data: '\x1b[ {
+ platformState.value = 'darwin'
+ render()
+ await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
+ const terminal = terminalHarness.instances[0]!
+ await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
+
+ const handled = terminal.customKeyHandler!(
+ new KeyboardEvent('keydown', { key: 'ArrowUp', code: 'ArrowUp', metaKey: true })
+ )
+
+ expect(handled).toBe(false)
+ expect(terminal.scrollToTop).toHaveBeenCalled()
+ expect(terminal.input).not.toHaveBeenCalled()
+ })
+
+ it('leaves an unmodified Backspace to xterm', async () => {
+ render()
+ await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
+ const terminal = terminalHarness.instances[0]!
+ await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
+
+ const handled = terminal.customKeyHandler!(
+ new KeyboardEvent('keydown', { key: 'Backspace', code: 'Backspace', cancelable: true })
+ )
+
+ expect(handled).toBe(true)
+ expect(terminal.input).not.toHaveBeenCalled()
+ })
+
it('leaves plain Ctrl+V to the Edit-menu accelerator but handles the shifted paste chord', async () => {
const view = render()
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx
index 4d01ce480c8..8da354166f7 100644
--- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx
+++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx
@@ -1,36 +1,33 @@
-import { useEffect, useMemo, useRef, useState } from 'react'
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Terminal } from '@xterm/xterm'
import '@xterm/xterm/css/xterm.css'
-import { buildDefaultTerminalOptions } from '@/lib/pane-manager/pane-terminal-options'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { subscribeToTerminalUserInput } from '@/components/terminal-pane/terminal-user-input-signal'
-import {
- executeTerminalPastePlan,
- planTerminalPasteWithYield
-} from '@/components/terminal-pane/terminal-paste-coordinator'
-import { resolveTerminalPasteRuntime } from '@/components/terminal-pane/terminal-paste-runtime'
-import { TERMINAL_PASTE_MAX_BYTES } from '@/components/terminal-pane/terminal-paste-limits'
-import {
- installTerminalImeCompositionTracker,
- type TerminalImeCompositionTracker
-} from '@/components/terminal-pane/terminal-ime-composition-tracker'
-import {
- installTerminalImeNativeTextForwarder,
- type TerminalImeNativeTextForwarder
-} from '@/components/terminal-pane/terminal-ime-native-text-forwarder'
-import { getMacNativeTextInputSourceTracker } from '@/components/terminal-pane/terminal-ime-input-source'
import { composeActiveTerminalTheme } from '@/components/terminal-pane/terminal-appearance'
-import { resolveTerminalMinimumContrastRatio } from '@/lib/terminal-contrast-correction'
import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark'
+import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
+import { useEffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/use-effective-mac-option-as-alt'
+import {
+ buildPreviewAppearanceOptions,
+ buildPreviewTerminalOptions
+} from './preview-terminal-options'
+import { syncPreviewTerminalLigatures } from './preview-terminal-ligatures'
+import { installPreviewTerminalCompatibility } from './preview-terminal-compatibility'
+import { createPreviewClipboardPaster } from './preview-terminal-paste'
+import { installPreviewImeBridge, type PreviewImeBridge } from './preview-terminal-ime-bridge'
+import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot'
import { translate } from '@/i18n/i18n'
import { getBuiltinTheme, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
-import { installPreviewClipboardShortcuts } from './preview-clipboard-shortcuts'
+import { installPreviewTerminalKeyHandler } from './preview-terminal-key-handler'
import { createPreviewGridClaim } from './preview-grid-claim'
import type { TerminalPreviewDataPayload } from '../../../../shared/terminal-preview'
const PREVIEW_SCROLLBACK_ROWS = 24
+// Why: main only ever serializes PREVIEW_SCROLLBACK_ROWS of history into this
+// terminal, so the pane's user-configured scrollback would only cost memory.
+const PREVIEW_SCROLLBACK_BUFFER_ROWS = 1000
const FALLBACK_COLS = 80
const FALLBACK_ROWS = 24
const RESYNC_RETRY_DELAY_MS = 150
@@ -50,10 +47,24 @@ function clamp(value: number, min: number, max: number): number {
* anchored so the cursor stays visible. Keystrokes pass through to the PTY;
* DOM renderer so it never grabs a WebGL context.
*/
-export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.Element {
+export function AgentTerminalPreview({
+ ptyId,
+ terminalInput = null
+}: {
+ ptyId: string
+ /** Host-input facts relayed with the card; null routes bytes by client OS. */
+ terminalInput?: DashboardCardTerminalInput | null
+}): React.JSX.Element {
const containerRef = useRef(null)
+ const terminalRef = useRef(null)
const settings = useAppStore((state) => state.settings)
const systemPrefersDark = useSystemPrefersDark()
+ const macOptionAsAlt = useEffectiveMacOptionAsAlt(settings?.terminalMacOptionAsAlt)
+ // Why: keys and appearance must read live values without remounting the
+ // terminal (a remount reconnects the pty and repaints from a new snapshot).
+ const settingsRef = useRef(settings)
+ const macOptionAsAltRef = useRef(macOptionAsAlt)
+ const terminalInputRef = useRef(terminalInput)
const { terminalTheme, terminalMode } = useMemo(() => {
if (!settings) {
return { terminalTheme: null, terminalMode: 'dark' as const }
@@ -69,6 +80,16 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
// spawned this session) — say so instead of painting a silent blank terminal.
const [ptyGone, setPtyGone] = useState(false)
+ // Why: refs are seeded at first render and refreshed on commit — assigning
+ // during render trips react-compiler. Layout, not passive: xterm's keydown is
+ // a native listener, so React would not flush a passive effect before the
+ // next keystroke and a just-relayed profile could miss it.
+ useLayoutEffect(() => {
+ settingsRef.current = settings
+ macOptionAsAltRef.current = macOptionAsAlt
+ terminalInputRef.current = terminalInput
+ }, [settings, macOptionAsAlt, terminalInput])
+
useEffect(() => {
setPtyGone(false)
const container = containerRef.current
@@ -79,8 +100,12 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
let terminal: Terminal | null = null
let offData: (() => void) | null = null
let userInputDisposable: { dispose: () => void } | null = null
- let imeCompositionTracker: TerminalImeCompositionTracker | null = null
- let imeNativeTextForwarder: TerminalImeNativeTextForwarder | null = null
+ let imeBridge: PreviewImeBridge | null = null
+ let disposeKeyHandler: (() => void) | null = null
+ let disposeTerminalCompatibility: (() => void) | null = null
+ // Why: mirrors the pane's tracker — the policy needs the flags the TUI
+ // negotiated, and this preview parses the same output stream the pane does.
+ const kittyKeyboardModes = new TerminalKittyKeyboardModeTracker()
let refreshInFlight = false
let refreshAgain = false
let retryTimer: ReturnType | null = null
@@ -136,7 +161,14 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
boxResizeObserver?.observe(container)
let replayDepth = 0
- const writeReplayed = (chunk: string, onDone?: () => void): void => {
+ const writeReplayed = (chunk: string, onDone?: () => void, live = false): void => {
+ // Why: a redelivered snapshot repeats the TUI's one-time kitty push, so
+ // replayed bytes must apply as idempotent sets (see the tracker's docs).
+ if (live) {
+ kittyKeyboardModes.scan(chunk)
+ } else {
+ kittyKeyboardModes.scanReplay(chunk)
+ }
replayDepth++
terminal?.write(chunk, () => {
replayDepth--
@@ -150,93 +182,63 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
pendingLivePayloads.push(payload)
return
}
- writeReplayed(payload.data, () => {
- if (!disposed) {
- void window.api.terminalPreview.ack(ptyId, payload.bytes)
- }
- })
+ writeReplayed(
+ payload.data,
+ () => {
+ if (!disposed) {
+ void window.api.terminalPreview.ack(ptyId, payload.bytes)
+ }
+ },
+ true
+ )
}
- const pasteClipboardText = async (
- activeElementAtDispatch: Element | null,
- source: 'keyboard' | 'app-menu'
- ): Promise => {
- let text: string
- try {
- text = await window.api.ui.readClipboardText({ maxBytes: TERMINAL_PASTE_MAX_BYTES })
- } catch {
- return
- }
- const pasteTerminal = terminal
- if (!pasteTerminal || !text) {
- return
- }
- const targetIsCurrent = (): boolean =>
- !disposed &&
- terminal === pasteTerminal &&
- activeElementAtDispatch !== null &&
- document.activeElement === activeElementAtDispatch &&
- container.contains(activeElementAtDispatch)
- if (!targetIsCurrent()) {
- return
- }
- const platform = getShortcutPlatform()
- const plan = await planTerminalPasteWithYield({
- text,
- source,
- target: {
- kind: 'terminal',
- paneId: 0,
- leafId: ptyId,
- ptyId,
- runtime: resolveTerminalPasteRuntime({ platform, ptyId })
- },
- terminalBracketedPasteMode: pasteTerminal.modes.bracketedPasteMode
- })
- await executeTerminalPastePlan(plan, {
- // Why: stream large pastes so the renderer never emits one huge IPC payload.
- pasteText: (pasteText) => pasteTerminal.paste(pasteText),
- writePty: (data) => window.api.terminalPreview.input(ptyId, data),
- isTargetCurrent: targetIsCurrent,
- // Why: if focus changes mid-bracketed paste, the closing marker must still reach the live PTY.
- canContinue: () => true
- })
- }
+ const pasteClipboardText = createPreviewClipboardPaster({
+ ptyId,
+ container,
+ getTerminal: () => terminal,
+ isDisposed: () => disposed
+ })
const disposeImeNativeTextBridge = (): void => {
- imeNativeTextForwarder?.dispose()
- imeNativeTextForwarder = null
- imeCompositionTracker?.dispose()
- imeCompositionTracker = null
+ imeBridge?.dispose()
+ imeBridge = null
}
- // Why: xterm's kitty encoder can encode+cancel a printable keydown before
- // Chromium commits IME/native text, silently dropping the glyph (mirrors
- // TerminalPane's forwarder; macOS-only like the pane's install).
const installImeNativeTextBridge = (): void => {
- if (!terminal || getShortcutPlatform() !== 'darwin') {
- return
+ if (terminal) {
+ imeBridge = installPreviewImeBridge(terminal)
}
- // Why: prewarm the async input-source lookup before the first native-text key needs classification.
- const inputSourceTracker = getMacNativeTextInputSourceTracker()
- imeCompositionTracker = installTerminalImeCompositionTracker(terminal.element)
- imeNativeTextForwarder = installTerminalImeNativeTextForwarder({
- terminalElement: terminal.element,
- isComposing: () => imeCompositionTracker?.isActive() ?? false,
- sendInput: (data) => terminal?.input(data),
- getInputSourceFeatures: () => inputSourceTracker.getFeatures()
- })
}
- const installClipboardShortcuts = (): void => {
+ const installKeyHandler = (): void => {
if (!terminal) {
return
}
- installPreviewClipboardShortcuts({
+ disposeKeyHandler = installPreviewTerminalKeyHandler({
terminal,
- claimImeKeyEvent: (event) => imeNativeTextForwarder?.claimKeyEvent(event) ?? false,
+ claimImeKeyEvent: (event) => imeBridge?.claimKeyEvent(event) ?? false,
pasteClipboardText: (activeElement, source) =>
- void pasteClipboardText(activeElement, source)
+ void pasteClipboardText(activeElement, source),
+ // Why: route through terminal.input so the chord's bytes carry core's user-input signal, like typed keys.
+ sendInput: (data) => terminal?.input(data),
+ getShortcutContext: () => ({
+ clientPlatform: getShortcutPlatform(),
+ macOptionAsAlt: macOptionAsAltRef.current,
+ keybindings: useAppStore.getState().keybindings,
+ terminalInput: terminalInputRef.current,
+ kittyKeyboardActive: () => kittyKeyboardModes.flags > 0,
+ terminalShortcutPolicy: settingsRef.current?.terminalShortcutPolicy
+ })
+ })
+ }
+
+ const installTerminalCompatibility = (): void => {
+ if (!terminal) {
+ return
+ }
+ disposeTerminalCompatibility = installPreviewTerminalCompatibility(terminal, {
+ getSettings: () => settingsRef.current
})
}
@@ -268,17 +270,18 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
): void => {
const snap = connection.snapshot!
if (!terminal) {
- terminal = new Terminal({
- ...buildDefaultTerminalOptions(),
- cols: clamp(snap.cols ?? FALLBACK_COLS, 2, 500),
- rows: clamp(snap.rows ?? FALLBACK_ROWS, 2, 200),
- theme: terminalTheme ?? undefined,
- minimumContrastRatio: resolveTerminalMinimumContrastRatio(
- terminalTheme?.background,
- terminalMode
- ),
- scrollback: 1000
- })
+ terminal = new Terminal(
+ buildPreviewTerminalOptions({
+ settings: settingsRef.current,
+ terminalInput: terminalInputRef.current,
+ macOptionIsMeta: macOptionAsAltRef.current === 'true',
+ theme: terminalTheme,
+ themeMode: terminalMode,
+ cols: clamp(snap.cols ?? FALLBACK_COLS, 2, 500),
+ rows: clamp(snap.rows ?? FALLBACK_ROWS, 2, 200),
+ scrollback: PREVIEW_SCROLLBACK_BUFFER_ROWS
+ })
+ )
try {
terminal.open(container)
} catch {
@@ -286,9 +289,11 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
terminal = null
return
}
+ terminalRef.current = terminal
+ installTerminalCompatibility()
installInputRouting()
installImeNativeTextBridge()
- installClipboardShortcuts()
+ installKeyHandler()
} else if (replaceExisting) {
// Why: keep the old frame visible during capture, then atomically replace it once the authoritative snapshot arrives.
terminal.resize(
@@ -296,6 +301,9 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
clamp(snap.rows ?? FALLBACK_ROWS, 2, 200)
)
terminal.reset()
+ // Why: the reset drops xterm's kitty flags, so the mirror must restart
+ // from the incoming snapshot instead of the dead session's state.
+ kittyKeyboardModes.reset()
}
if (snap.scrollbackAnsi) {
writeReplayed(snap.scrollbackAnsi)
@@ -355,8 +363,13 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
userInputDisposable?.dispose()
userInputDisposable = null
disposeImeNativeTextBridge()
+ disposeTerminalCompatibility?.()
+ disposeTerminalCompatibility = null
+ disposeKeyHandler?.()
+ disposeKeyHandler = null
terminal?.dispose()
terminal = null
+ terminalRef.current = null
void window.api.terminalPreview.unsubscribe(ptyId)
return
}
@@ -402,11 +415,29 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
offData?.()
userInputDisposable?.dispose()
disposeImeNativeTextBridge()
+ disposeTerminalCompatibility?.()
+ disposeKeyHandler?.()
void window.api.terminalPreview.unsubscribe(ptyId)
terminal?.dispose()
+ terminalRef.current = null
}
}, [ptyId, terminalTheme, terminalMode])
+ // Why: appearance settings must land on the open terminal, and the OS input
+ // source can flip Option-as-Alt with no settings change at all. A remount
+ // would reconnect the pty and repaint the agent's screen from a new snapshot.
+ useEffect(() => {
+ const terminal = terminalRef.current
+ if (!terminal) {
+ return
+ }
+ Object.assign(
+ terminal.options,
+ buildPreviewAppearanceOptions(settings, macOptionAsAlt === 'true')
+ )
+ syncPreviewTerminalLigatures(terminal, settings)
+ }, [settings, macOptionAsAlt])
+
return (
// Why: a size FIXED by the viewport (not shrink-to-fit) + overflow-hidden
// keeps the dialog stable no matter how wide/tall the pane's serialized
diff --git a/src/renderer/src/components/dashboard-popout/preview-clipboard-shortcuts.ts b/src/renderer/src/components/dashboard-popout/preview-clipboard-shortcuts.ts
deleted file mode 100644
index d23873e9028..00000000000
--- a/src/renderer/src/components/dashboard-popout/preview-clipboard-shortcuts.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import type { Terminal } from '@xterm/xterm'
-import { getShortcutPlatform } from '@/lib/shortcut-platform'
-import { keybindingMatchesAction } from '../../../../shared/keybindings'
-import { useAppStore } from '@/store'
-
-/**
- * Installs the popout preview terminal's ONE custom key handler (xterm allows
- * a single attachCustomKeyEventHandler) covering copy/paste chords and the
- * IME native-text bypass. Plain Mod+V is left to the Edit-menu accelerator,
- * which reaches this window as ui:appMenuPaste — matching it here too would
- * paste twice.
- */
-export function installPreviewClipboardShortcuts(args: {
- terminal: Terminal
- claimImeKeyEvent: (event: KeyboardEvent) => boolean
- pasteClipboardText: (activeElement: Element | null, source: 'keyboard') => void
-}): void {
- const { terminal } = args
- const platform = getShortcutPlatform()
- const consumedClipboardKeys = new Set()
- const consumeEvent = (event: KeyboardEvent): false => {
- event.preventDefault()
- event.stopPropagation()
- return false
- }
- terminal.attachCustomKeyEventHandler((event) => {
- if (args.claimImeKeyEvent(event)) {
- // Why: bypass xterm's kitty encoder for native-text keydowns so the committed glyph survives via the input event.
- return false
- }
- if (event.type !== 'keydown') {
- const keyIdentity = event.code || event.key
- if (consumedClipboardKeys.has(keyIdentity)) {
- if (event.type === 'keyup') {
- consumedClipboardKeys.delete(keyIdentity)
- }
- return consumeEvent(event)
- }
- return true
- }
- const keybindings = useAppStore.getState().keybindings
- if (keybindingMatchesAction('terminal.copySelection', event, platform, keybindings)) {
- const keyIdentity = event.code || event.key
- const firstKeydown = !consumedClipboardKeys.has(keyIdentity)
- consumedClipboardKeys.add(keyIdentity)
- const selection = terminal.getSelection()
- if (firstKeydown && selection) {
- void window.api.ui.writeClipboardText(selection).catch(() => undefined)
- }
- return consumeEvent(event)
- }
- const isMenuPasteChord =
- (platform === 'darwin' ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey) &&
- !event.altKey &&
- !event.shiftKey &&
- event.key.toLowerCase() === 'v'
- if (
- !isMenuPasteChord &&
- keybindingMatchesAction('terminal.paste', event, platform, keybindings)
- ) {
- const keyIdentity = event.code || event.key
- if (!consumedClipboardKeys.has(keyIdentity)) {
- consumedClipboardKeys.add(keyIdentity)
- args.pasteClipboardText(document.activeElement, 'keyboard')
- }
- return consumeEvent(event)
- }
- return true
- })
-}
diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-compatibility.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-compatibility.ts
new file mode 100644
index 00000000000..0693bdff81a
--- /dev/null
+++ b/src/renderer/src/components/dashboard-popout/preview-terminal-compatibility.ts
@@ -0,0 +1,51 @@
+import type { Terminal } from '@xterm/xterm'
+import { Unicode11Addon } from '@xterm/addon-unicode11'
+import type { GlobalSettings } from '../../../../shared/types'
+import { activateOrcaTerminalUnicodeProvider } from '../../../../shared/terminal-unicode-provider'
+import { installWindowsCtrlAltChordRepair } from '@/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification'
+import { attachTerminalMouseWheelMultiplier } from '@/lib/pane-manager/pane-terminal-mouse-wheel'
+import { configureLazyArabicShapingJoiner } from '@/lib/pane-manager/terminal-arabic-shaping-joiner'
+import { installTerminalImeCandidateAnchor } from '@/lib/pane-manager/terminal-ime-candidate-anchor'
+import { normalizeTerminalTuiMouseWheelMultiplier } from '@/lib/pane-manager/pane-terminal-tui-wheel-reports'
+import { installPreviewTerminalLinks } from './preview-terminal-links'
+import { syncPreviewTerminalLigatures } from './preview-terminal-ligatures'
+
+/**
+ * Brings the preview's emulator up to a pane's: Orca's Unicode 11 width shim,
+ * Windows Ctrl+Alt chord classification, clickable links, ligatures, the TUI
+ * wheel multiplier, lazy Arabic shaping, and the IME candidate anchor.
+ *
+ * Returns a disposer for everything bound to the terminal's DOM element, which
+ * must run before that terminal is disposed or replaced.
+ */
+export function installPreviewTerminalCompatibility(
+ terminal: Terminal,
+ deps: { getSettings: () => GlobalSettings | null }
+): () => void {
+ // Why: the width shim wraps xterm's v11 provider, so the addon that registers
+ // it has to load first — and both must precede any replay write, or wide
+ // chars bake into the buffer at xterm's default v6 tables (same order as
+ // the pane's openTerminal).
+ terminal.loadAddon(new Unicode11Addon())
+ activateOrcaTerminalUnicodeProvider(terminal)
+ installWindowsCtrlAltChordRepair(terminal)
+ installPreviewTerminalLinks(terminal)
+ syncPreviewTerminalLigatures(terminal, deps.getSettings())
+ attachTerminalMouseWheelMultiplier(terminal, {
+ getTuiMouseWheelMultiplier: () =>
+ normalizeTerminalTuiMouseWheelMultiplier(deps.getSettings()?.terminalTuiScrollSensitivity)
+ })
+ // Why: joiners cost a full-grid scan per repaint, so registration stays lazy
+ // until RTL text arrives. Never shaping-active: the preview is DOM-rendered
+ // and has no WebGL glyph atlas to compensate for.
+ const disposeArabicShapingJoiner = configureLazyArabicShapingJoiner(terminal, () => false)
+ const imeAnchorHandler = installTerminalImeCandidateAnchor(terminal)
+
+ return () => {
+ if (imeAnchorHandler && terminal.element) {
+ terminal.element.removeEventListener('compositionstart', imeAnchorHandler)
+ terminal.element.removeEventListener('compositionupdate', imeAnchorHandler)
+ }
+ disposeArabicShapingJoiner()
+ }
+}
diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge.ts
new file mode 100644
index 00000000000..f61a93ab028
--- /dev/null
+++ b/src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge.ts
@@ -0,0 +1,40 @@
+import type { Terminal } from '@xterm/xterm'
+import { getShortcutPlatform } from '@/lib/shortcut-platform'
+import { installTerminalImeCompositionTracker } from '@/components/terminal-pane/terminal-ime-composition-tracker'
+import { installTerminalImeNativeTextForwarder } from '@/components/terminal-pane/terminal-ime-native-text-forwarder'
+import { getMacNativeTextInputSourceTracker } from '@/components/terminal-pane/terminal-ime-input-source'
+
+export type PreviewImeBridge = {
+ /** True when the forwarder owns this keydown, so xterm must not encode it. */
+ claimKeyEvent: (event: KeyboardEvent) => boolean
+ dispose: () => void
+}
+
+/**
+ * Native-text bridge for the preview terminal.
+ *
+ * Why: xterm's kitty encoder can encode+cancel a printable keydown before
+ * Chromium commits IME/native text, silently dropping the glyph. Mirrors
+ * TerminalPane's forwarder, macOS-only like the pane's install.
+ */
+export function installPreviewImeBridge(terminal: Terminal): PreviewImeBridge | null {
+ if (getShortcutPlatform() !== 'darwin') {
+ return null
+ }
+ // Why: prewarm the async input-source lookup before the first native-text key needs classification.
+ const inputSourceTracker = getMacNativeTextInputSourceTracker()
+ const compositionTracker = installTerminalImeCompositionTracker(terminal.element)
+ const forwarder = installTerminalImeNativeTextForwarder({
+ terminalElement: terminal.element,
+ isComposing: () => compositionTracker?.isActive() ?? false,
+ sendInput: (data) => terminal.input(data),
+ getInputSourceFeatures: () => inputSourceTracker.getFeatures()
+ })
+ return {
+ claimKeyEvent: (event) => forwarder?.claimKeyEvent(event) ?? false,
+ dispose: () => {
+ forwarder?.dispose()
+ compositionTracker?.dispose()
+ }
+ }
+}
diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts
new file mode 100644
index 00000000000..4eb7157e43d
--- /dev/null
+++ b/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts
@@ -0,0 +1,182 @@
+import type { Terminal } from '@xterm/xterm'
+import { getShortcutPlatform } from '@/lib/shortcut-platform'
+import { keybindingMatchesAction } from '../../../../shared/keybindings'
+import { useAppStore } from '@/store'
+import { prefetchLayoutBaseCharacters } from '@/lib/keyboard-layout/layout-base-character'
+import { createTerminalNativeOnlyShortcutTracker } from '@/components/terminal-pane/terminal-native-only-shortcut'
+import {
+ resolvePreviewShortcutAction,
+ type PreviewShortcutContext
+} from './preview-terminal-shortcuts'
+
+/**
+ * Installs the preview terminal's ONE custom key handler (xterm allows a single
+ * attachCustomKeyEventHandler) covering copy/paste chords, the IME native-text
+ * bypass, and the full pane shortcut policy. Plain Mod+V is left to the
+ * Edit-menu accelerator, which reaches this window as ui:appMenuPaste — matching
+ * it here too would paste twice.
+ *
+ * Returns a disposer for the Option-key location listeners the policy needs to
+ * tell left Option from right.
+ */
+export function installPreviewTerminalKeyHandler(args: {
+ terminal: Terminal
+ claimImeKeyEvent: (event: KeyboardEvent) => boolean
+ pasteClipboardText: (activeElement: Element | null, source: 'keyboard') => void
+ sendInput: (data: string) => void
+ /** Everything but optionKeyLocation, which this installer tracks itself. */
+ getShortcutContext: () => Omit
+}): () => void {
+ const { terminal } = args
+ const platform = getShortcutPlatform()
+ const consumedClipboardKeys = new Set()
+ const nativeOnlyShortcutTracker = createTerminalNativeOnlyShortcutTracker()
+ const consumeEvent = (event: KeyboardEvent): false => {
+ event.preventDefault()
+ event.stopPropagation()
+ return false
+ }
+
+ // Why: a character key's KeyboardEvent.location reports its own position, so
+ // left-vs-right Option must be recorded from the modifier's own keydown.
+ let optionKeyLocation = 0
+ const onModifierDown = (event: KeyboardEvent): void => {
+ if (event.key === 'Alt') {
+ optionKeyLocation = event.location
+ }
+ }
+ const onModifierUp = (event: KeyboardEvent): void => {
+ if (event.key === 'Alt') {
+ optionKeyLocation = 0
+ }
+ }
+ const onWindowBlur = (): void => {
+ optionKeyLocation = 0
+ nativeOnlyShortcutTracker.clear()
+ }
+ const onNativeOnlyShortcutCompanion = (event: KeyboardEvent): void => {
+ if (!nativeOnlyShortcutTracker.consumeCompanion(event)) {
+ return
+ }
+ if (event.type === 'keypress') {
+ event.preventDefault()
+ }
+ event.stopImmediatePropagation()
+ }
+ const onNativeOnlyBeforeInput = (event: Event): void => {
+ if (
+ !(event instanceof InputEvent) ||
+ !nativeOnlyShortcutTracker.shouldSuppressBeforeInput(event)
+ ) {
+ return
+ }
+ event.preventDefault()
+ event.stopImmediatePropagation()
+ }
+ if (platform === 'darwin') {
+ // Why: kitty Option-chord encoding resolves base keys through the async
+ // KeyboardLayoutMap; prefetch so the map is cached before the first chord.
+ prefetchLayoutBaseCharacters()
+ }
+ window.addEventListener('keydown', onModifierDown, true)
+ window.addEventListener('keyup', onModifierUp, true)
+ window.addEventListener('keypress', onNativeOnlyShortcutCompanion, true)
+ window.addEventListener('keyup', onNativeOnlyShortcutCompanion, true)
+ window.addEventListener('beforeinput', onNativeOnlyBeforeInput, true)
+ window.addEventListener('blur', onWindowBlur)
+
+ terminal.attachCustomKeyEventHandler((event) => {
+ if (args.claimImeKeyEvent(event)) {
+ // Why: bypass xterm's kitty encoder for native-text keydowns so the committed glyph survives via the input event.
+ return false
+ }
+ if (event.type !== 'keydown') {
+ const keyIdentity = event.code || event.key
+ if (consumedClipboardKeys.has(keyIdentity)) {
+ if (event.type === 'keyup') {
+ consumedClipboardKeys.delete(keyIdentity)
+ }
+ return consumeEvent(event)
+ }
+ return true
+ }
+ nativeOnlyShortcutTracker.prepareKeyDown(event)
+ const keybindings = useAppStore.getState().keybindings
+ if (keybindingMatchesAction('terminal.copySelection', event, platform, keybindings)) {
+ const keyIdentity = event.code || event.key
+ const firstKeydown = !consumedClipboardKeys.has(keyIdentity)
+ consumedClipboardKeys.add(keyIdentity)
+ const selection = terminal.getSelection()
+ if (firstKeydown && selection) {
+ void window.api.ui.writeClipboardText(selection).catch(() => undefined)
+ }
+ return consumeEvent(event)
+ }
+ const isMenuPasteChord =
+ (platform === 'darwin' ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey) &&
+ !event.altKey &&
+ !event.shiftKey &&
+ event.key.toLowerCase() === 'v'
+ if (
+ !isMenuPasteChord &&
+ keybindingMatchesAction('terminal.paste', event, platform, keybindings)
+ ) {
+ const keyIdentity = event.code || event.key
+ if (!consumedClipboardKeys.has(keyIdentity)) {
+ consumedClipboardKeys.add(keyIdentity)
+ args.pasteClipboardText(document.activeElement, 'keyboard')
+ }
+ return consumeEvent(event)
+ }
+
+ const action = resolvePreviewShortcutAction(event, {
+ ...args.getShortcutContext(),
+ optionKeyLocation
+ })
+ if (!action) {
+ return true
+ }
+ switch (action.type) {
+ case 'sendInput':
+ args.sendInput(action.data)
+ return consumeEvent(event)
+ case 'scrollViewport':
+ if (action.position === 'top') {
+ terminal.scrollToTop()
+ } else {
+ terminal.scrollToBottom()
+ }
+ return consumeEvent(event)
+ case 'switchInputSource':
+ // Why: the OS owns this chord — block xterm without preventing the default.
+ nativeOnlyShortcutTracker.armKeyDown(event)
+ event.stopImmediatePropagation()
+ return false
+ // Why: pane-scoped chords have no target in a preview dialog. Swallow them
+ // — a pane never sends these bytes to the shell, and xterm would encode
+ // e.g. Ctrl+Shift+D as a bare Ctrl+D. Listed one by one rather than under a
+ // `default` so a newly added action has to be classified here, not
+ // silently swallowed.
+ case 'clearActivePane':
+ case 'clearPaneTitle':
+ case 'closeActivePane':
+ case 'copySelection':
+ case 'equalizePaneSizes':
+ case 'focusPane':
+ case 'setTitle':
+ case 'splitActivePane':
+ case 'toggleExpandActivePane':
+ case 'toggleSearch':
+ return consumeEvent(event)
+ }
+ })
+
+ return () => {
+ window.removeEventListener('keydown', onModifierDown, true)
+ window.removeEventListener('keyup', onModifierUp, true)
+ window.removeEventListener('keypress', onNativeOnlyShortcutCompanion, true)
+ window.removeEventListener('keyup', onNativeOnlyShortcutCompanion, true)
+ window.removeEventListener('beforeinput', onNativeOnlyBeforeInput, true)
+ window.removeEventListener('blur', onWindowBlur)
+ }
+}
diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-ligatures.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-ligatures.ts
new file mode 100644
index 00000000000..cc0cb2036fd
--- /dev/null
+++ b/src/renderer/src/components/dashboard-popout/preview-terminal-ligatures.ts
@@ -0,0 +1,43 @@
+import type { Terminal } from '@xterm/xterm'
+import type { GlobalSettings } from '../../../../shared/types'
+import { resolveTerminalLigaturesEnabled } from '../../../../shared/terminal-ligatures'
+import { TerminalLigaturesAddon } from '@/lib/pane-manager/terminal-ligatures-addon'
+
+const ligatureAddonsByTerminal = new WeakMap()
+
+/**
+ * Match the pane's ligature state on the preview terminal, attaching and
+ * detaching as the setting (or a font that can't ligate) changes. The pane's
+ * WebGL atlas rebuild has no counterpart here — the preview is DOM-rendered.
+ */
+export function syncPreviewTerminalLigatures(
+ terminal: Terminal,
+ settings: GlobalSettings | null
+): void {
+ const enabled = resolveTerminalLigaturesEnabled(
+ settings?.terminalLigatures,
+ settings?.terminalFontFamily
+ )
+ const attached = ligatureAddonsByTerminal.get(terminal)
+ if (enabled === Boolean(attached)) {
+ return
+ }
+ if (!enabled) {
+ try {
+ attached?.dispose()
+ } catch {
+ /* ignore */
+ }
+ ligatureAddonsByTerminal.delete(terminal)
+ return
+ }
+ try {
+ const addon = new TerminalLigaturesAddon()
+ terminal.loadAddon(addon)
+ ligatureAddonsByTerminal.set(terminal, addon)
+ // Why: ligatures can turn on after rows rendered; force a glyph-run recompute.
+ terminal.refresh(0, terminal.rows - 1)
+ } catch {
+ /* ignore: ligatures are cosmetic, never fail the preview over them */
+ }
+}
diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-links.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-links.ts
new file mode 100644
index 00000000000..bbc521a52ad
--- /dev/null
+++ b/src/renderer/src/components/dashboard-popout/preview-terminal-links.ts
@@ -0,0 +1,26 @@
+import type { Terminal } from '@xterm/xterm'
+import { WebLinksAddon } from '@xterm/addon-web-links'
+import { installGuardedLinkProviderRegistration } from '@/lib/pane-manager/terminal-link-provider-guard'
+import { isTerminalHttpLinkActivation } from '@/components/terminal-pane/terminal-http-link-activation'
+
+/**
+ * Makes URLs in the preview clickable under the same Mod+click gesture a pane
+ * uses. Links always open in the system browser: Orca's in-app browser routing
+ * is workspace-scoped, and the pop-out window hosts no browser pane.
+ */
+export function installPreviewTerminalLinks(terminal: Terminal): void {
+ // Why: a link provider throwing inside provideLinks (xterm's LinkComputer
+ // raises RangeError on pathological wrapped lines) escapes to window.onerror
+ // and kills the renderer — guard before any provider registers.
+ installGuardedLinkProviderRegistration(terminal)
+ terminal.loadAddon(
+ new WebLinksAddon((event, uri) => {
+ if (!isTerminalHttpLinkActivation(event)) {
+ return
+ }
+ event.preventDefault()
+ void window.api.shell.openUrl(uri).catch(() => undefined)
+ terminal.clearSelection()
+ })
+ )
+}
diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts
new file mode 100644
index 00000000000..790334ace6e
--- /dev/null
+++ b/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts
@@ -0,0 +1,92 @@
+// @vitest-environment happy-dom
+
+import { describe, expect, it } from 'vitest'
+import type { GlobalSettings } from '../../../../shared/types'
+import {
+ buildPreviewAppearanceOptions,
+ buildPreviewTerminalOptions
+} from './preview-terminal-options'
+
+const SETTINGS = {
+ terminalFontSize: 17,
+ terminalFontFamily: 'Fira Code',
+ terminalCursorStyle: 'bar',
+ terminalCursorBlink: false,
+ terminalLineHeight: 1.4,
+ terminalWordSeparator: ' ()[]',
+ terminalScrollSensitivity: 3
+} as unknown as GlobalSettings
+
+describe('buildPreviewAppearanceOptions', () => {
+ it('carries the user terminal appearance a pane would apply', () => {
+ const options = buildPreviewAppearanceOptions(SETTINGS, false)
+ expect(options.fontSize).toBe(17)
+ expect(options.fontFamily).toContain('Fira Code')
+ expect(options.cursorStyle).toBe('bar')
+ expect(options.cursorBlink).toBe(false)
+ expect(options.lineHeight).toBe(1.4)
+ expect(options.wordSeparator).toBe(' ()[]')
+ expect(options.scrollSensitivity).toBe(3)
+ // Why: a bar cursor's inactive style must not become xterm's block outline.
+ expect(options.cursorInactiveStyle).toBe('bar')
+ })
+
+ it('enables macOptionIsMeta only for the resolved full Option-as-Alt', () => {
+ expect(buildPreviewAppearanceOptions(SETTINGS, true).macOptionIsMeta).toBe(true)
+ expect(buildPreviewAppearanceOptions(SETTINGS, false).macOptionIsMeta).toBe(false)
+ })
+
+ it('falls back to pane defaults with no settings hydrated', () => {
+ const options = buildPreviewAppearanceOptions(null, false)
+ expect(options.fontSize).toBe(14)
+ expect(options.cursorBlink).toBe(true)
+ })
+})
+
+describe('buildPreviewTerminalOptions', () => {
+ const base = {
+ settings: SETTINGS,
+ macOptionIsMeta: false,
+ theme: null,
+ themeMode: 'dark' as const,
+ cols: 100,
+ rows: 30,
+ scrollback: 1000
+ }
+
+ it('keeps the kitty advertisement and skips ConPTY options off Windows', () => {
+ const options = buildPreviewTerminalOptions({
+ ...base,
+ terminalInput: {
+ hostPlatform: 'darwin',
+ localWindowsConpty: false,
+ windowsShiftEnterEncoding: 'alt-enter',
+ kittyKeyboardAdvertised: true
+ }
+ })
+ expect(options.vtExtensions?.kittyKeyboard).toBe(true)
+ expect(options.windowsPty).toBeUndefined()
+ expect(options.cols).toBe(100)
+ })
+
+ it('mirrors the ConPTY backend and kitty withhold a local Windows pane resolves', () => {
+ const options = buildPreviewTerminalOptions({
+ ...base,
+ terminalInput: {
+ hostPlatform: 'win32',
+ localWindowsConpty: true,
+ osRelease: '10.0.22631',
+ windowsShiftEnterEncoding: 'alt-enter',
+ kittyKeyboardAdvertised: false
+ }
+ })
+ expect(options.windowsPty).toEqual({ backend: 'conpty', buildNumber: 22631 })
+ expect(options.vtExtensions?.kittyKeyboard).toBe(false)
+ })
+
+ it('keeps the advertised default when the card carries no host profile', () => {
+ const options = buildPreviewTerminalOptions({ ...base, terminalInput: null })
+ expect(options.vtExtensions?.kittyKeyboard).toBe(true)
+ expect(options.windowsPty).toBeUndefined()
+ })
+})
diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts
new file mode 100644
index 00000000000..bfbaae29998
--- /dev/null
+++ b/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts
@@ -0,0 +1,83 @@
+import type { ITerminalInitOnlyOptions, ITerminalOptions, ITheme } from '@xterm/xterm'
+import type { GlobalSettings } from '../../../../shared/types'
+import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot'
+import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts'
+import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings'
+import {
+ buildDefaultTerminalOptions,
+ normalizeTerminalFastScrollSensitivity,
+ normalizeTerminalScrollSensitivity,
+ resolveTerminalCursorInactiveStyle
+} from '@/lib/pane-manager/pane-terminal-options'
+import { buildLocalConptyTerminalOptions } from '@/lib/pane-manager/windows-pty-compatibility'
+import { buildFontFamily } from '@/components/terminal-pane/layout-serialization'
+import { resolveTerminalMinimumContrastRatio } from '@/lib/terminal-contrast-correction'
+
+/** Options a live settings change can write onto an open preview terminal. */
+export function buildPreviewAppearanceOptions(
+ settings: GlobalSettings | null,
+ macOptionIsMeta: boolean
+): Partial {
+ const cursorStyle = settings?.terminalCursorStyle ?? 'block'
+ const fontWeights = resolveTerminalFontWeights(settings?.terminalFontWeight)
+ return {
+ fontSize: settings?.terminalFontSize ?? 14,
+ fontFamily: buildFontFamily(settings?.terminalFontFamily ?? ''),
+ fontWeight: fontWeights.fontWeight,
+ fontWeightBold: fontWeights.fontWeightBold,
+ cursorStyle,
+ cursorInactiveStyle: resolveTerminalCursorInactiveStyle(cursorStyle),
+ cursorBlink: settings?.terminalCursorBlink ?? true,
+ scrollSensitivity: normalizeTerminalScrollSensitivity(settings?.terminalScrollSensitivity),
+ fastScrollSensitivity: normalizeTerminalFastScrollSensitivity(
+ settings?.terminalFastScrollSensitivity
+ ),
+ lineHeight: normalizeTerminalLineHeight(settings?.terminalLineHeight),
+ wordSeparator: settings?.terminalWordSeparator,
+ // Why only 'true': 'left'/'right' are handled by the keydown policy, which needs Option composable at the xterm level.
+ macOptionIsMeta,
+ // Why: xterm renders an alpha background opaque unless transparency is on (matches applyTerminalAppearance).
+ allowTransparency:
+ settings?.terminalBackgroundOpacity !== undefined && settings.terminalBackgroundOpacity < 1
+ }
+}
+
+/**
+ * Full option set for the preview's xterm: the same defaults, user appearance,
+ * and host compatibility flags a pane resolves, so the agent's TUI negotiates
+ * with an identically-configured emulator. Scrollback stays preview-sized —
+ * main only ever serializes a short history window into this terminal.
+ */
+export function buildPreviewTerminalOptions(args: {
+ settings: GlobalSettings | null
+ terminalInput: DashboardCardTerminalInput | null
+ macOptionIsMeta: boolean
+ theme: ITheme | null
+ themeMode: 'dark' | 'light'
+ cols: number
+ rows: number
+ scrollback: number
+}): ITerminalOptions & ITerminalInitOnlyOptions {
+ const hostCompatibility: Partial = {
+ ...(args.terminalInput?.localWindowsConpty
+ ? buildLocalConptyTerminalOptions(args.terminalInput.osRelease)
+ : {}),
+ // Why: local ConPTY CLIs read the advertisement but can't decode CSI-u (#2434); mirror the pane's withhold.
+ ...(args.terminalInput && !args.terminalInput.kittyKeyboardAdvertised
+ ? { vtExtensions: { kittyKeyboard: false } }
+ : {})
+ }
+ return {
+ ...buildDefaultTerminalOptions(),
+ ...buildPreviewAppearanceOptions(args.settings, args.macOptionIsMeta),
+ ...hostCompatibility,
+ cols: args.cols,
+ rows: args.rows,
+ scrollback: args.scrollback,
+ theme: args.theme ?? undefined,
+ minimumContrastRatio: resolveTerminalMinimumContrastRatio(
+ args.theme?.background,
+ args.themeMode
+ )
+ }
+}
diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-paste.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-paste.ts
new file mode 100644
index 00000000000..6738502d60a
--- /dev/null
+++ b/src/renderer/src/components/dashboard-popout/preview-terminal-paste.ts
@@ -0,0 +1,63 @@
+import type { Terminal } from '@xterm/xterm'
+import { getShortcutPlatform } from '@/lib/shortcut-platform'
+import {
+ executeTerminalPastePlan,
+ planTerminalPasteWithYield
+} from '@/components/terminal-pane/terminal-paste-coordinator'
+import { resolveTerminalPasteRuntime } from '@/components/terminal-pane/terminal-paste-runtime'
+import { TERMINAL_PASTE_MAX_BYTES } from '@/components/terminal-pane/terminal-paste-limits'
+
+/**
+ * Clipboard paste for the preview terminal, on the pane's coordinator: large
+ * pastes stream as bounded IPC payloads, and the plan re-checks that the same
+ * terminal still owns focus between chunks.
+ */
+export function createPreviewClipboardPaster(deps: {
+ ptyId: string
+ container: HTMLElement
+ getTerminal: () => Terminal | null
+ isDisposed: () => boolean
+}): (activeElementAtDispatch: Element | null, source: 'keyboard' | 'app-menu') => Promise {
+ return async (activeElementAtDispatch, source) => {
+ let text: string
+ try {
+ text = await window.api.ui.readClipboardText({ maxBytes: TERMINAL_PASTE_MAX_BYTES })
+ } catch {
+ return
+ }
+ const pasteTerminal = deps.getTerminal()
+ if (!pasteTerminal || !text) {
+ return
+ }
+ const targetIsCurrent = (): boolean =>
+ !deps.isDisposed() &&
+ deps.getTerminal() === pasteTerminal &&
+ activeElementAtDispatch !== null &&
+ document.activeElement === activeElementAtDispatch &&
+ deps.container.contains(activeElementAtDispatch)
+ if (!targetIsCurrent()) {
+ return
+ }
+ const platform = getShortcutPlatform()
+ const plan = await planTerminalPasteWithYield({
+ text,
+ source,
+ target: {
+ kind: 'terminal',
+ paneId: 0,
+ leafId: deps.ptyId,
+ ptyId: deps.ptyId,
+ runtime: resolveTerminalPasteRuntime({ platform, ptyId: deps.ptyId })
+ },
+ terminalBracketedPasteMode: pasteTerminal.modes.bracketedPasteMode
+ })
+ await executeTerminalPastePlan(plan, {
+ // Why: stream large pastes so the renderer never emits one huge IPC payload.
+ pasteText: (pasteText) => pasteTerminal.paste(pasteText),
+ writePty: (data) => window.api.terminalPreview.input(deps.ptyId, data),
+ isTargetCurrent: targetIsCurrent,
+ // Why: if focus changes mid-bracketed paste, the closing marker must still reach the live PTY.
+ canContinue: () => true
+ })
+ }
+}
diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts
new file mode 100644
index 00000000000..b76c7c14699
--- /dev/null
+++ b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts
@@ -0,0 +1,148 @@
+// @vitest-environment happy-dom
+
+import { describe, expect, it } from 'vitest'
+import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot'
+import {
+ resolvePreviewShortcutAction,
+ type PreviewShortcutContext
+} from './preview-terminal-shortcuts'
+
+const LOCAL_MAC: DashboardCardTerminalInput = {
+ hostPlatform: 'darwin',
+ localWindowsConpty: false,
+ windowsShiftEnterEncoding: 'alt-enter',
+ kittyKeyboardAdvertised: true
+}
+
+function contextFor(
+ overrides: Partial & { kitty?: boolean } = {}
+): PreviewShortcutContext {
+ return {
+ clientPlatform: 'darwin',
+ macOptionAsAlt: 'false',
+ optionKeyLocation: 0,
+ keybindings: undefined,
+ terminalInput: LOCAL_MAC,
+ kittyKeyboardActive: () => overrides.kitty === true,
+ terminalShortcutPolicy: 'orca-first',
+ ...overrides
+ }
+}
+
+function keydown(init: KeyboardEventInit): KeyboardEvent {
+ return new KeyboardEvent('keydown', init)
+}
+
+describe('resolvePreviewShortcutAction', () => {
+ it('kills the previous word on Ctrl+Backspace instead of deleting one character', () => {
+ expect(
+ resolvePreviewShortcutAction(keydown({ key: 'Backspace', ctrlKey: true }), contextFor())
+ ).toEqual({ type: 'sendInput', data: '\x17' })
+ })
+
+ it('maps the macOS line-kill and word-navigation chords', () => {
+ const context = contextFor()
+ expect(
+ resolvePreviewShortcutAction(keydown({ key: 'Backspace', metaKey: true }), context)
+ ).toEqual({ type: 'sendInput', data: '\x15' })
+ expect(
+ resolvePreviewShortcutAction(keydown({ key: 'Delete', metaKey: true }), context)
+ ).toEqual({ type: 'sendInput', data: '\x0b' })
+ expect(
+ resolvePreviewShortcutAction(keydown({ key: 'ArrowLeft', altKey: true }), context)
+ ).toEqual({ type: 'sendInput', data: '\x1bb' })
+ expect(
+ resolvePreviewShortcutAction(keydown({ key: 'ArrowUp', metaKey: true }), context)
+ ).toEqual({ type: 'scrollViewport', position: 'top' })
+ })
+
+ it('leaves Option chords to xterm while the TUI has kitty mode negotiated', () => {
+ const kittyContext = contextFor({ kitty: true })
+ expect(
+ resolvePreviewShortcutAction(keydown({ key: 'Backspace', altKey: true }), kittyContext)
+ ).toBeNull()
+ expect(
+ resolvePreviewShortcutAction(keydown({ key: 'Backspace', altKey: true }), contextFor())
+ ).toEqual({ type: 'sendInput', data: '\x1b\x7f' })
+ })
+
+ it('withholds the Ctrl+arrow translation on a local ConPTY pty, where PSReadLine binds it', () => {
+ const conpty = contextFor({
+ clientPlatform: 'win32',
+ terminalInput: {
+ hostPlatform: 'win32',
+ localWindowsConpty: true,
+ windowsShiftEnterEncoding: 'alt-enter',
+ kittyKeyboardAdvertised: false
+ }
+ })
+ expect(
+ resolvePreviewShortcutAction(keydown({ key: 'ArrowLeft', ctrlKey: true }), conpty)
+ ).toBeNull()
+ expect(
+ resolvePreviewShortcutAction(
+ keydown({ key: 'ArrowLeft', ctrlKey: true }),
+ contextFor({ clientPlatform: 'linux', terminalInput: null })
+ )
+ ).toEqual({ type: 'sendInput', data: '\x1bb' })
+ })
+
+ it('routes Shift+Enter by the PTY host, not the client OS', () => {
+ // A macOS client driving a Windows runtime that negotiated CSI-u.
+ const windowsHost = contextFor({
+ terminalInput: {
+ hostPlatform: 'win32',
+ localWindowsConpty: false,
+ windowsShiftEnterEncoding: 'csi-u',
+ kittyKeyboardAdvertised: true
+ }
+ })
+ expect(
+ resolvePreviewShortcutAction(keydown({ key: 'Enter', shiftKey: true }), windowsHost)
+ ).toEqual({ type: 'sendInput', data: '\x1b[13;2u' })
+ expect(
+ resolvePreviewShortcutAction(keydown({ key: 'Enter', shiftKey: true }), contextFor())
+ ).toEqual({ type: 'sendInput', data: '\x1b\r' })
+ })
+
+ it('reports pane-scoped chords so the caller can swallow them', () => {
+ expect(
+ resolvePreviewShortcutAction(keydown({ key: 'd', code: 'KeyD', metaKey: true }), contextFor())
+ ).toEqual({ type: 'splitActivePane', direction: 'vertical' })
+ })
+
+ // Why: a terminal-first user remapped terminal.closePane away, so only the
+ // tab.close alias still matches Ctrl+W — which the shell owns as a word-kill.
+ // The pane yields it; the preview must not swallow it.
+ it('follows the terminal shortcut policy for the tab.close pane-close alias', () => {
+ const keybindings = {
+ 'terminal.closePane': ['Mod+Shift+W'],
+ 'tab.close': ['Ctrl+W']
+ } as unknown as PreviewShortcutContext['keybindings']
+ const chord = (): KeyboardEvent => keydown({ key: 'w', code: 'KeyW', ctrlKey: true })
+ expect(
+ resolvePreviewShortcutAction(
+ chord(),
+ contextFor({ clientPlatform: 'linux', terminalInput: null, keybindings })
+ )
+ ).toEqual({ type: 'closeActivePane' })
+ expect(
+ resolvePreviewShortcutAction(
+ chord(),
+ contextFor({
+ clientPlatform: 'linux',
+ terminalInput: null,
+ keybindings,
+ terminalShortcutPolicy: 'terminal-first'
+ })
+ )
+ ).toBeNull()
+ })
+
+ it('leaves ordinary typing to xterm', () => {
+ expect(
+ resolvePreviewShortcutAction(keydown({ key: 'a', code: 'KeyA' }), contextFor())
+ ).toBeNull()
+ expect(resolvePreviewShortcutAction(keydown({ key: 'Backspace' }), contextFor())).toBeNull()
+ })
+})
diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts
new file mode 100644
index 00000000000..e235c8d4992
--- /dev/null
+++ b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts
@@ -0,0 +1,58 @@
+import {
+ resolveTerminalShortcutAction,
+ type MacOptionAsAlt,
+ type TerminalShortcutAction
+} from '@/components/terminal-pane/terminal-shortcut-policy'
+import { getLayoutBaseCharacterForCode } from '@/lib/keyboard-layout/layout-base-character'
+import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot'
+import {
+ normalizeTerminalShortcutPolicy,
+ type KeybindingOverrides,
+ type TerminalShortcutPolicy
+} from '../../../../shared/keybindings'
+
+export type PreviewShortcutContext = {
+ clientPlatform: NodeJS.Platform
+ macOptionAsAlt: MacOptionAsAlt
+ /** Location of the physically held Option key (character keys report their own). */
+ optionKeyLocation: number
+ keybindings: KeybindingOverrides | undefined
+ /** Host-input facts relayed with the card; null falls back to client-OS routing. */
+ terminalInput: DashboardCardTerminalInput | null
+ /** Live kitty-protocol flags mirrored from this pty's output. */
+ kittyKeyboardActive: () => boolean
+ /** The user's setting; terminal-first yields the tab.close alias to the shell. */
+ terminalShortcutPolicy: TerminalShortcutPolicy | null | undefined
+}
+
+/**
+ * Runs the preview terminal's keys through the same policy a pane uses, so the
+ * dashboard encodes word-kills, Option chords, and modified Enter identically.
+ * Every pane-scoped verdict (splits, search, focus) still comes back — the
+ * caller swallows those rather than leaking raw bytes to the agent.
+ */
+export function resolvePreviewShortcutAction(
+ event: KeyboardEvent,
+ context: PreviewShortcutContext
+): TerminalShortcutAction | null {
+ const isMac = context.clientPlatform === 'darwin'
+ const hostPlatform = context.terminalInput?.hostPlatform ?? context.clientPlatform
+ return resolveTerminalShortcutAction(
+ event,
+ isMac,
+ context.macOptionAsAlt,
+ context.optionKeyLocation,
+ context.clientPlatform === 'win32',
+ context.keybindings,
+ // Why: PSReadLine on a local ConPTY binds Ctrl+←/→ itself; \eb/\ef would print stray b/f.
+ () => context.terminalInput?.localWindowsConpty === true,
+ context.kittyKeyboardActive,
+ getLayoutBaseCharacterForCode,
+ () => context.terminalInput?.windowsShiftEnterEncoding ?? 'alt-enter',
+ // Why: byte protocols follow the pty's host, which differs from the client OS on remote runtimes.
+ () => hostPlatform === 'win32',
+ // Why: without it a terminal-first user's remapped tab.close chord — Ctrl+W
+ // is a shell word-kill — reaches the shell in the pane but is swallowed here.
+ normalizeTerminalShortcutPolicy(context.terminalShortcutPolicy)
+ )
+}
diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts
index a2821b094c8..aa3534eac4d 100644
--- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts
+++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts
@@ -394,9 +394,23 @@ describe('buildDashboardSnapshot', () => {
expect(snapshot.cards[0].workspaceStatusId).toBeUndefined()
expect(snapshot.cards[0].subagents).toBeUndefined()
+ // Why: the card has a live pty, so only the count-path gate keeps the
+ // host-input resolution off the sidebar's per-status-tick rebuild.
+ expect(snapshot.cards[0].ptyId).toBe('pty1')
+ expect(snapshot.cards[0].terminalInput).toBeUndefined()
expect(linkedReviewReads).toBe(0)
})
+ it("resolves a live pty's host-input profile for card snapshots", () => {
+ const snapshot = buildDashboardSnapshot(
+ baseState({ agentStatusByPaneKey: { [PANE_KEY]: entry({}) } }),
+ NOW
+ )
+
+ expect(snapshot.cards[0].terminalInput?.windowsShiftEnterEncoding).toBe('alt-enter')
+ expect(snapshot.cards[0].terminalInput?.kittyKeyboardAdvertised).toBe(true)
+ })
+
it('relays the idle-column setting in the serialized snapshot', () => {
const snapshot = buildDashboardSnapshot(
baseState({
diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts
index e51313ad16c..c7c3c9711cc 100644
--- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts
+++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts
@@ -10,6 +10,11 @@ import {
import type { RepoIcon } from '../../../../shared/repo-icon'
import { DEFAULT_WORKSPACE_STATUSES } from '../../../../shared/workspace-statuses'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
+import {
+ resolveDashboardCardTerminalInput,
+ type DashboardCardTerminalInputState
+} from './dashboard-card-terminal-input'
+import { readDashboardClientHost } from './dashboard-client-host'
import { getAgentRowConversationName } from '../../../../shared/agent-row-conversation-name'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import { applyAgentRowLineage } from './agent-row-lineage'
@@ -54,7 +59,8 @@ export type DashboardSnapshotState = Pick<
| 'acknowledgedAgentsByPaneKey'
| 'settings'
> &
- DashboardCardContextState
+ DashboardCardContextState &
+ Partial
function bucketForState(state: DashboardAgentRow['state']): DashboardBucket {
switch (state) {
@@ -123,6 +129,7 @@ export function buildDashboardSnapshot(
options: { includeCardDetails?: boolean; includeFilterOptions?: boolean } = {}
): DashboardSnapshot {
const cards: DashboardCard[] = []
+ const clientHost = readDashboardClientHost()
const repoIconsByRepoId: Record = {}
const includeCardDetails = options.includeCardDetails !== false
const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true
@@ -262,6 +269,23 @@ export function buildDashboardSnapshot(
: null
const dotState = row.state as DashboardCardDotState
const bucket = bucketForState(row.state)
+ // Why: only a live pty can open a preview terminal, and only a
+ // card-rendering caller can open one — the sidebar's bucket counts must
+ // not pay host resolution on every agent-status tick.
+ const terminalInput =
+ ptyId && includeCardDetails
+ ? resolveDashboardCardTerminalInput(state, {
+ ptyId,
+ worktreeId,
+ paneKey: routingPaneKey,
+ cwd: row.tab.startupCwd ?? worktree.path,
+ shellOverride: row.tab.shellOverride,
+ launchAgent: row.tab.launchAgent,
+ clientPlatform: clientHost.platform,
+ userAgent: clientHost.userAgent,
+ osRelease: clientHost.osRelease
+ })
+ : null
// Only repos that actually contribute a card ship their icon.
repoIconsByRepoId[repo.id] = repo.repoIcon ?? null
@@ -295,7 +319,8 @@ export function buildDashboardSnapshot(
!isTitleDerived &&
(state.acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt,
askSummary: bucket === 'attention' ? (row.entry.interactivePrompt ?? undefined) : undefined,
- conversationName: boundedLabelOrUndefined(rowConversationName(row, generatedTitlesEnabled))
+ conversationName: boundedLabelOrUndefined(rowConversationName(row, generatedTitlesEnabled)),
+ ...(terminalInput ? { terminalInput } : {})
})
}
}
diff --git a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts
new file mode 100644
index 00000000000..d7aad2ce5b4
--- /dev/null
+++ b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts
@@ -0,0 +1,144 @@
+// @vitest-environment happy-dom
+
+import { describe, expect, it } from 'vitest'
+import {
+ resolveDashboardCardTerminalInput,
+ type DashboardCardTerminalInputState
+} from './dashboard-card-terminal-input'
+
+const MAC_ARGS = {
+ ptyId: 'pty-1',
+ worktreeId: 'wt-1',
+ paneKey: 'tab-1:00000000-0000-4000-8000-000000000001',
+ cwd: '/Users/dev/repo',
+ shellOverride: undefined,
+ launchAgent: undefined,
+ clientPlatform: 'darwin' as NodeJS.Platform,
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
+ osRelease: undefined
+}
+
+const WINDOWS_ARGS = {
+ ...MAC_ARGS,
+ cwd: 'C:\\repo',
+ clientPlatform: 'win32' as NodeJS.Platform,
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/131.0.0.0',
+ osRelease: '10.0.22631'
+}
+
+function stateWith(
+ overrides: Partial = {}
+): Partial {
+ return {
+ repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }],
+ worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] },
+ ...overrides
+ } as Partial
+}
+
+describe('resolveDashboardCardTerminalInput', () => {
+ it('resolves a local macOS pane to client-host routing with kitty advertised', () => {
+ expect(resolveDashboardCardTerminalInput(stateWith(), MAC_ARGS)).toEqual({
+ hostPlatform: 'darwin',
+ localWindowsConpty: false,
+ windowsShiftEnterEncoding: 'alt-enter',
+ kittyKeyboardAdvertised: true
+ })
+ })
+
+ it('marks a local native Windows pane as ConPTY and withholds the kitty advertisement', () => {
+ const profile = resolveDashboardCardTerminalInput(stateWith(), WINDOWS_ARGS)
+ expect(profile.localWindowsConpty).toBe(true)
+ expect(profile.kittyKeyboardAdvertised).toBe(false)
+ expect(profile.osRelease).toBe('10.0.22631')
+ })
+
+ it('keeps the kitty advertisement on ConPTY for an agent that needs CSI-u', () => {
+ const profile = resolveDashboardCardTerminalInput(stateWith(), {
+ ...WINDOWS_ARGS,
+ launchAgent: 'grok'
+ })
+ expect(profile.localWindowsConpty).toBe(true)
+ expect(profile.kittyKeyboardAdvertised).toBe(true)
+ })
+
+ // Why: the pty runs Linux inside WSL, so byte protocols must follow it and
+ // not the Windows client — the pane resolves this from its own session cwd.
+ it('treats a WSL shell override as a non-ConPTY Linux-hosted pty', () => {
+ const profile = resolveDashboardCardTerminalInput(stateWith(), {
+ ...WINDOWS_ARGS,
+ shellOverride: 'wsl.exe'
+ })
+ expect(profile.localWindowsConpty).toBe(false)
+ expect(profile.kittyKeyboardAdvertised).toBe(true)
+ expect(profile.hostPlatform).toBe('linux')
+ })
+
+ it('follows the WSL host for a UNC session path with no shell override', () => {
+ const profile = resolveDashboardCardTerminalInput(stateWith(), {
+ ...WINDOWS_ARGS,
+ cwd: '\\\\wsl$\\Ubuntu\\home\\dev\\repo'
+ })
+ expect(profile.hostPlatform).toBe('linux')
+ expect(profile.localWindowsConpty).toBe(false)
+ })
+
+ it('keeps a native Windows pane on the client host', () => {
+ expect(resolveDashboardCardTerminalInput(stateWith(), WINDOWS_ARGS).hostPlatform).toBe('win32')
+ })
+
+ it('follows the SSH host platform rather than the client OS', () => {
+ const state = stateWith({
+ repos: [{ id: 'repo-1', connectionId: 'conn-1', executionHostId: 'ssh:conn-1' }],
+ sshConnectionStates: new Map([['conn-1', { remotePlatform: 'win32' }]])
+ } as unknown as Partial)
+ expect(resolveDashboardCardTerminalInput(state, MAC_ARGS).hostPlatform).toBe('win32')
+ })
+
+ it("keeps the live SSH PTY's host after the worktree owner changes", () => {
+ const state = stateWith({
+ sshConnectionStates: new Map([['conn-live', { remotePlatform: 'win32' }]])
+ } as unknown as Partial)
+ const profile = resolveDashboardCardTerminalInput(state, {
+ ...MAC_ARGS,
+ ptyId: 'ssh:conn-live@@pty-1'
+ })
+ expect(profile.hostPlatform).toBe('win32')
+ expect(profile.localWindowsConpty).toBe(false)
+ })
+
+ it("keeps the live runtime PTY's host after the worktree owner changes", () => {
+ const state = stateWith({
+ runtimeStatusByEnvironmentId: new Map([['env-live', { status: { hostPlatform: 'linux' } }]])
+ } as unknown as Partial)
+ const profile = resolveDashboardCardTerminalInput(state, {
+ ...WINDOWS_ARGS,
+ ptyId: 'remote:env-live@@pty-1'
+ })
+ expect(profile.hostPlatform).toBe('linux')
+ expect(profile.localWindowsConpty).toBe(false)
+ expect(profile.kittyKeyboardAdvertised).toBe(true)
+ })
+
+ it('degrades to client-OS routing when the store has not hydrated', () => {
+ expect(resolveDashboardCardTerminalInput({}, MAC_ARGS)).toEqual({
+ hostPlatform: 'darwin',
+ localWindowsConpty: false,
+ windowsShiftEnterEncoding: 'alt-enter',
+ kittyKeyboardAdvertised: true
+ })
+ })
+
+ it('does not enumerate unrelated store slices per card', () => {
+ const state = stateWith() as Partial & {
+ unrelatedSlice?: unknown
+ }
+ Object.defineProperty(state, 'unrelatedSlice', {
+ enumerable: true,
+ get: () => {
+ throw new Error('unrelated store slice was read')
+ }
+ })
+ expect(() => resolveDashboardCardTerminalInput(state, MAC_ARGS)).not.toThrow()
+ })
+})
diff --git a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts
new file mode 100644
index 00000000000..ab7fc611c75
--- /dev/null
+++ b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts
@@ -0,0 +1,132 @@
+import type { AppState } from '@/store/types'
+import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot'
+import type { TuiAgent } from '../../../../shared/types'
+import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host'
+import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
+import { getConnectionIdFromState } from '@/lib/connection-context'
+import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
+import { shouldDisableKittyKeyboardForTerminal } from '@/lib/pane-manager/terminal-keyboard-protocol'
+import { isLocalNativeWindowsConpty } from '@/lib/pane-manager/windows-pty-compatibility'
+import { resolveTerminalInputHostPlatform } from '@/components/terminal-pane/terminal-input-host-platform'
+import { resolveWindowsShiftEnterEncodingForPane } from '@/components/terminal-pane/terminal-windows-shift-enter'
+import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
+
+/** Store slices the pane's own input helpers read. */
+export type DashboardCardTerminalInputState = Pick<
+ AppState,
+ | 'repos'
+ | 'worktreesByRepo'
+ | 'detectedWorktreesByRepo'
+ | 'folderWorkspaces'
+ | 'projectGroups'
+ | 'settings'
+ | 'sshConnectionStates'
+ | 'sshStateByEnvironment'
+ | 'runtimeStatusByEnvironmentId'
+ | 'restoredRuntimeHostIdByWorkspaceSessionKey'
+ | 'runtimeEnvironments'
+ | 'runtimeEnvironmentCatalogHydrated'
+ | 'removedRuntimeEnvironmentIds'
+ | 'paneForegroundAgentByPaneKey'
+ | 'agentLaunchConfigByPaneKey'
+>
+
+const EMPTY_RECORD = {} as Record
+
+/** Why: the snapshot builder must never throw on a partially hydrated store —
+ * a missing slice degrades one card to client-OS defaults, not a blank board. */
+function withHydratedSlices(
+ state: Partial
+): DashboardCardTerminalInputState {
+ return {
+ repos: state.repos ?? [],
+ worktreesByRepo: state.worktreesByRepo ?? EMPTY_RECORD,
+ detectedWorktreesByRepo: state.detectedWorktreesByRepo ?? EMPTY_RECORD,
+ folderWorkspaces: state.folderWorkspaces ?? [],
+ projectGroups: state.projectGroups ?? [],
+ settings: state.settings ?? null,
+ sshConnectionStates: state.sshConnectionStates ?? new Map(),
+ sshStateByEnvironment: state.sshStateByEnvironment ?? new Map(),
+ runtimeStatusByEnvironmentId: state.runtimeStatusByEnvironmentId ?? new Map(),
+ restoredRuntimeHostIdByWorkspaceSessionKey:
+ state.restoredRuntimeHostIdByWorkspaceSessionKey ?? EMPTY_RECORD,
+ runtimeEnvironments: state.runtimeEnvironments ?? [],
+ runtimeEnvironmentCatalogHydrated: state.runtimeEnvironmentCatalogHydrated ?? false,
+ removedRuntimeEnvironmentIds: state.removedRuntimeEnvironmentIds ?? new Set(),
+ paneForegroundAgentByPaneKey: state.paneForegroundAgentByPaneKey ?? EMPTY_RECORD,
+ agentLaunchConfigByPaneKey: state.agentLaunchConfigByPaneKey ?? EMPTY_RECORD
+ }
+}
+
+/**
+ * Resolve the host-dependent input facts a card's preview terminal needs to
+ * encode keys like the agent's own pane. Store-derived only — the board holds no
+ * live PtyTransport, so the shared pane helpers run their fallback path, the
+ * same one a pane itself uses before its transport attaches.
+ */
+export function resolveDashboardCardTerminalInput(
+ partialState: Partial,
+ args: {
+ ptyId: string
+ worktreeId: string
+ paneKey: string
+ /** Worktree path — decides WSL vs native Windows for a local pty. */
+ cwd: string
+ shellOverride: string | null | undefined
+ launchAgent: TuiAgent | null | undefined
+ clientPlatform: NodeJS.Platform
+ userAgent: string
+ osRelease: string | undefined
+ }
+): DashboardCardTerminalInput {
+ const state = withHydratedSlices(partialState)
+ const sshPty = parseAppSshPtyId(args.ptyId)
+ const runtimeEnvironmentId = getRemoteRuntimePtyEnvironmentId(args.ptyId)
+ const connectionId =
+ sshPty?.connectionId ??
+ (runtimeEnvironmentId ? null : getConnectionIdFromState(state, args.worktreeId))
+ const executionHostId = sshPty
+ ? toSshExecutionHostId(sshPty.connectionId)
+ : runtimeEnvironmentId
+ ? toRuntimeExecutionHostId(runtimeEnvironmentId)
+ : getExecutionHostIdForWorktree(state, args.worktreeId)
+ const windowsPtyContext = {
+ userAgent: args.userAgent,
+ osRelease: args.osRelease,
+ connectionId,
+ cwd: args.cwd,
+ shellOverride: args.shellOverride,
+ executionHostId
+ }
+ return {
+ hostPlatform: resolveTerminalInputHostPlatform({
+ clientPlatform: args.clientPlatform,
+ state,
+ worktreeId: args.worktreeId,
+ transport: {
+ getConnectionId: () => connectionId,
+ getPtyId: () => args.ptyId,
+ getExecutionHostId: () => executionHostId,
+ // Why: gated exactly like the pane transport's own accessor — without
+ // it a WSL pty on a Windows client resolves to win32 and Shift+Enter
+ // would encode CSI-u where the pane sends alt-enter.
+ getLocalSessionMetadata: () =>
+ connectionId
+ ? null
+ : {
+ ...(args.cwd ? { cwd: args.cwd } : {}),
+ ...(args.shellOverride ? { shellOverride: args.shellOverride } : {})
+ }
+ }
+ }),
+ localWindowsConpty: isLocalNativeWindowsConpty(windowsPtyContext),
+ ...(args.osRelease === undefined ? {} : { osRelease: args.osRelease }),
+ windowsShiftEnterEncoding: resolveWindowsShiftEnterEncodingForPane(state, args.paneKey),
+ kittyKeyboardAdvertised: !shouldDisableKittyKeyboardForTerminal({
+ ...windowsPtyContext,
+ // Why: launch identity is the only agent signal the board holds; it opts
+ // Grok out of the ConPTY kitty withhold exactly as its pane does.
+ tuiAgent: args.launchAgent ?? null
+ })
+ }
+}
diff --git a/src/renderer/src/components/dashboard/dashboard-client-host.ts b/src/renderer/src/components/dashboard/dashboard-client-host.ts
new file mode 100644
index 00000000000..adb507b60da
--- /dev/null
+++ b/src/renderer/src/components/dashboard/dashboard-client-host.ts
@@ -0,0 +1,32 @@
+/**
+ * The facts about the machine *rendering* the board, as opposed to the machine
+ * executing a pty. A card's preview terminal needs both: byte encodings follow
+ * the pty's host, while xterm's own ConPTY wrap-marker compat follows the client.
+ */
+export type DashboardClientHost = {
+ platform: NodeJS.Platform
+ userAgent: string
+ osRelease: string | undefined
+}
+
+export function readDashboardClientHost(): DashboardClientHost {
+ const userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent
+ return {
+ platform: userAgent.includes('Mac')
+ ? 'darwin'
+ : userAgent.includes('Windows')
+ ? 'win32'
+ : 'linux',
+ userAgent,
+ // Why: absent in the pop-out renderer and in tests, where no preload rides along.
+ osRelease: readClientOsRelease()
+ }
+}
+
+function readClientOsRelease(): string | undefined {
+ try {
+ return window.api?.platform?.get?.()?.osRelease
+ } catch {
+ return undefined
+ }
+}
diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx
index 1bc03a6b4d1..e4d853d6e28 100644
--- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx
+++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx
@@ -67,8 +67,21 @@ function makeSnapshotWatchState(): DashboardSnapshotWatchState {
runtimePaneTitlesByTabId: {},
acknowledgedAgentsByPaneKey: {},
settings: null,
- agentStatusEpoch: 0
- }
+ agentStatusEpoch: 0,
+ // Why: seeded with real identities so the profile assertions below compare
+ // two distinct values — omitting them would pass against `undefined`.
+ sshConnectionStates: new Map(),
+ sshStateByEnvironment: new Map(),
+ runtimeStatusByEnvironmentId: new Map(),
+ paneForegroundAgentByPaneKey: {},
+ detectedWorktreesByRepo: {},
+ folderWorkspaces: [],
+ projectGroups: [],
+ restoredRuntimeHostIdByWorkspaceSessionKey: {},
+ runtimeEnvironments: [],
+ runtimeEnvironmentCatalogHydrated: false,
+ removedRuntimeEnvironmentIds: new Set()
+ } as DashboardSnapshotWatchState
}
function Harness({ enabled }: { enabled: boolean }): null {
@@ -167,6 +180,30 @@ describe('useDashboardPopoutBridge', () => {
expect(
dashboardSnapshotInputsChanged({ ...previousState, agentStatusEpoch: 1 }, previousState)
).toBe(true)
+
+ // Why: each card's preview terminal keys against a host-input profile
+ // derived from these. Not republishing leaves the pop-out encoding bytes
+ // for the host the pty used to run on.
+ const profileInputs: Partial[] = [
+ { sshConnectionStates: new Map() },
+ { sshStateByEnvironment: new Map() },
+ { runtimeStatusByEnvironmentId: new Map() },
+ { paneForegroundAgentByPaneKey: {} },
+ { detectedWorktreesByRepo: {} },
+ // A folder workspace is not a git worktree; its host resolves through these.
+ { folderWorkspaces: [] },
+ { projectGroups: [] },
+ { restoredRuntimeHostIdByWorkspaceSessionKey: {} },
+ { runtimeEnvironments: [] },
+ { runtimeEnvironmentCatalogHydrated: true },
+ { removedRuntimeEnvironmentIds: new Set() }
+ ]
+ const republished = profileInputs
+ .filter((next) =>
+ dashboardSnapshotInputsChanged({ ...previousState, ...next }, previousState)
+ )
+ .map((next) => Object.keys(next)[0])
+ expect(republished).toEqual(profileInputs.map((next) => Object.keys(next)[0]))
})
it('releases every dashboard listener when the experiment is disabled', async () => {
diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts
index abde797896e..0c0d26909cc 100644
--- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts
+++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts
@@ -58,7 +58,27 @@ export function dashboardSnapshotInputsChanged(
state.settings !== previousState.settings ||
state.workspaceStatuses !== previousState.workspaceStatuses ||
// Why: freshness can change a bucket without replacing any backing map.
- state.agentStatusEpoch !== previousState.agentStatusEpoch
+ state.agentStatusEpoch !== previousState.agentStatusEpoch ||
+ // Why: each card carries the host-input profile its preview terminal keys
+ // against, and the pop-out cannot re-derive it. Every slice that resolves
+ // an execution host must republish or the preview keeps encoding bytes for
+ // the host the pty used to run on — a quiet board has no later publish to
+ // heal from. All are low-frequency except the foreground agent, whose churn
+ // the publish throttle already absorbs.
+ state.sshConnectionStates !== previousState.sshConnectionStates ||
+ state.sshStateByEnvironment !== previousState.sshStateByEnvironment ||
+ state.runtimeStatusByEnvironmentId !== previousState.runtimeStatusByEnvironmentId ||
+ state.paneForegroundAgentByPaneKey !== previousState.paneForegroundAgentByPaneKey ||
+ state.detectedWorktreesByRepo !== previousState.detectedWorktreesByRepo ||
+ // Why: a folder workspace is not a git worktree — its host resolves through
+ // these two instead of worktreesByRepo.
+ state.folderWorkspaces !== previousState.folderWorkspaces ||
+ state.projectGroups !== previousState.projectGroups ||
+ state.restoredRuntimeHostIdByWorkspaceSessionKey !==
+ previousState.restoredRuntimeHostIdByWorkspaceSessionKey ||
+ state.runtimeEnvironments !== previousState.runtimeEnvironments ||
+ state.runtimeEnvironmentCatalogHydrated !== previousState.runtimeEnvironmentCatalogHydrated ||
+ state.removedRuntimeEnvironmentIds !== previousState.removedRuntimeEnvironmentIds
)
}
diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts
index 9ebaa72fb5e..2b6c35db8f0 100644
--- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts
+++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts
@@ -123,4 +123,42 @@ describe('useLiveDashboardSnapshot', () => {
'repo-1': { type: 'lucide', name: 'Rocket' }
})
})
+
+ // Why: the profile resolves from connection/runtime slices this hook does not
+ // subscribe to. Dropping one degrades the drawer's preview terminal to
+ // client-OS byte routing while the pop-out keeps encoding correctly.
+ it("resolves each card's host-input profile from the slices it does not subscribe to", () => {
+ seed({ tabAutoGenerateTitle: false })
+ const { result: localResult } = renderHook(() => useLiveDashboardSnapshot())
+ expect(localResult.current.cards[0].terminalInput?.hostPlatform).toBe('linux')
+
+ useAppStore.setState({
+ repos: [{ ...repo(), connectionId: 'conn-1', executionHostId: 'ssh:conn-1' }],
+ sshConnectionStates: new Map([
+ ['conn-1', { remotePlatform: 'win32' }]
+ ]) as unknown as ReturnType['sshConnectionStates']
+ })
+ const { result: sshResult } = renderHook(() => useLiveDashboardSnapshot())
+ expect(sshResult.current.cards[0].terminalInput?.hostPlatform).toBe('win32')
+ })
+
+ // Why: a fresh renderHook always recomputes, so only re-rendering the SAME
+ // hook proves the memo watches the slice. An idle board publishes nothing
+ // else, so a missed subscription never heals.
+ it('re-derives the host-input profile when only a host slice changes', () => {
+ seed({ tabAutoGenerateTitle: false })
+ useAppStore.setState({
+ repos: [{ ...repo(), connectionId: 'conn-1', executionHostId: 'ssh:conn-1' }]
+ })
+ const { result, rerender } = renderHook(() => useLiveDashboardSnapshot())
+ expect(result.current.cards[0].terminalInput?.hostPlatform).toBe('linux')
+
+ useAppStore.setState({
+ sshConnectionStates: new Map([
+ ['conn-1', { remotePlatform: 'win32' }]
+ ]) as unknown as ReturnType['sshConnectionStates']
+ })
+ rerender()
+ expect(result.current.cards[0].terminalInput?.hostPlatform).toBe('win32')
+ })
})
diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts
index fe8e99dd8ff..43933bfae62 100644
--- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts
+++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts
@@ -28,6 +28,29 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
// Why: controls idle visibility and gates generated conversation names.
const settings = useAppStore((s) => s.settings)
const workspaceStatuses = useAppStore((s) => s.workspaceStatuses)
+ // Why: each card carries the host-input profile its preview terminal keys
+ // against, so the drawer must watch every slice that resolves an execution
+ // host — exactly the set useDashboardPopoutBridge republishes on. Agent
+ // activity alone cannot heal these: a board whose agents are idle never
+ // rebuilds, so an SSH handshake or host swap would leave the preview encoding
+ // bytes for the host the pty used to run on. All are low-frequency (each
+ // writer bails out when nothing changed) next to agentStatusByPaneKey, which
+ // already rebuilds this memo on every status ping.
+ const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo)
+ // Why: a folder workspace is not a git worktree — its host resolves through
+ // these two instead of worktreesByRepo.
+ const folderWorkspaces = useAppStore((s) => s.folderWorkspaces)
+ const projectGroups = useAppStore((s) => s.projectGroups)
+ const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
+ const sshStateByEnvironment = useAppStore((s) => s.sshStateByEnvironment)
+ const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId)
+ const restoredRuntimeHostIdByWorkspaceSessionKey = useAppStore(
+ (s) => s.restoredRuntimeHostIdByWorkspaceSessionKey
+ )
+ const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
+ const runtimeEnvironmentCatalogHydrated = useAppStore((s) => s.runtimeEnvironmentCatalogHydrated)
+ const removedRuntimeEnvironmentIds = useAppStore((s) => s.removedRuntimeEnvironmentIds)
+ const paneForegroundAgentByPaneKey = useAppStore((s) => s.paneForegroundAgentByPaneKey)
// Why: freshness can flip a bucket without any backing map changing; the epoch
// ticks on the freshness boundary so the memo re-derives stale-decayed cards.
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
@@ -52,7 +75,22 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
hostedReviewCache,
prCache,
settings,
- workspaceStatuses
+ workspaceStatuses,
+ detectedWorktreesByRepo,
+ folderWorkspaces,
+ projectGroups,
+ sshConnectionStates,
+ sshStateByEnvironment,
+ runtimeStatusByEnvironmentId,
+ restoredRuntimeHostIdByWorkspaceSessionKey,
+ runtimeEnvironments,
+ runtimeEnvironmentCatalogHydrated,
+ removedRuntimeEnvironmentIds,
+ paneForegroundAgentByPaneKey,
+ // Why: read non-reactively — resolveWindowsShiftEnterEncoding takes
+ // launch identity but never routes on it, so subscribing would only
+ // rebuild the board. Matches the bridge's republish gate.
+ agentLaunchConfigByPaneKey: useAppStore.getState().agentLaunchConfigByPaneKey
},
Date.now()
),
@@ -73,6 +111,17 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
prCache,
settings,
workspaceStatuses,
+ detectedWorktreesByRepo,
+ folderWorkspaces,
+ projectGroups,
+ sshConnectionStates,
+ sshStateByEnvironment,
+ runtimeStatusByEnvironmentId,
+ restoredRuntimeHostIdByWorkspaceSessionKey,
+ runtimeEnvironments,
+ runtimeEnvironmentCatalogHydrated,
+ removedRuntimeEnvironmentIds,
+ paneForegroundAgentByPaneKey,
agentStatusEpoch
]
)
diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts
index 91cffb1e6d8..dd6f41e4301 100644
--- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts
+++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts
@@ -15,7 +15,7 @@ import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync'
import { attachWebgl, cancelPendingWebglRefresh, disposeWebgl } from './pane-webgl-renderer'
import { configureLazyArabicShapingJoiner } from './terminal-arabic-shaping-joiner'
import { TerminalLigaturesAddon } from './terminal-ligatures-addon'
-import { resolveCursorAgentImeAnchor } from './terminal-ime-anchor'
+import { installTerminalImeCandidateAnchor } from './terminal-ime-candidate-anchor'
// ---------------------------------------------------------------------------
// Pane creation, terminal open/close, addon management
@@ -84,61 +84,8 @@ export function openTerminal(pane: ManagedPaneInternal): void {
() => pane.webglAddon != null
)
- // Why: the OS reads the focused textarea's screen rect at compositionstart to
- // decide where to display the IME candidate window. xterm positions that
- // textarea from its own cursor, which can be stale or intentionally hidden by
- // TUIs. We force-sync after xterm's own composition handlers so the OS sees
- // the corrected location before it opens the candidate window.
- //
- // Cell dimensions are derived from the public .xterm-screen element's bounds
- // (xterm sizes that element to cols*cellWidth × rows*cellHeight) rather than
- // poking `_core._renderService.dimensions` — keeps us on the public API
- // surface so upgrades don't silently regress the fix.
- if (terminal.element && terminal.textarea) {
- const screenElement = terminal.element.querySelector('.xterm-screen')
- const textarea = terminal.textarea
- const handler = (): void => {
- if (!screenElement) {
- return
- }
- const rect = screenElement.getBoundingClientRect()
- const cellWidth = rect.width / terminal.cols
- const cellHeight = rect.height / terminal.rows
- if (!(cellWidth > 0) || !(cellHeight > 0)) {
- return
- }
- const buf = terminal.buffer.active
- // Why: Cursor Agent draws its prompt UI while leaving xterm's public cursor
- // on a blank row, so the OS IME anchor needs the rendered prompt row instead.
- const cursorAgentAnchor = resolveCursorAgentImeAnchor({
- buffer: buf,
- rows: terminal.rows,
- cols: terminal.cols,
- cursorX: buf.cursorX,
- cursorY: buf.cursorY
- })
- const anchor = cursorAgentAnchor ?? {
- row: buf.cursorY,
- column: Math.min(buf.cursorX, terminal.cols - 1)
- }
- const applyAnchor = (): void => {
- textarea.style.top = `${anchor.row * cellHeight}px`
- textarea.style.left = `${anchor.column * cellWidth}px`
- }
- applyAnchor()
- if (cursorAgentAnchor) {
- window.setTimeout(() => {
- if (textarea.isConnected) {
- applyAnchor()
- }
- }, 0)
- }
- }
- terminal.element.addEventListener('compositionstart', handler)
- terminal.element.addEventListener('compositionupdate', handler)
- // Store so disposePane() can remove it and avoid a memory leak.
- pane.compositionHandler = handler
- }
+ // Store so disposePane() can remove it and avoid a memory leak.
+ pane.compositionHandler = installTerminalImeCandidateAnchor(terminal)
pane.focusClassSyncCleanup = attachDomRendererFocusClassSync(terminal.element)
diff --git a/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.ts b/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.ts
new file mode 100644
index 00000000000..c8979c6f315
--- /dev/null
+++ b/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.ts
@@ -0,0 +1,67 @@
+import type { Terminal } from '@xterm/xterm'
+import { resolveCursorAgentImeAnchor } from './terminal-ime-anchor'
+
+/**
+ * Keep the OS IME candidate window anchored to the cell the user is typing in.
+ *
+ * Why: the OS reads the focused textarea's screen rect at compositionstart to
+ * decide where to display the candidate window. xterm positions that textarea
+ * from its own cursor, which can be stale or intentionally hidden by TUIs. We
+ * force-sync after xterm's own composition handlers so the OS sees the corrected
+ * location before it opens the candidate window.
+ *
+ * Cell dimensions are derived from the public .xterm-screen element's bounds
+ * (xterm sizes that element to cols*cellWidth × rows*cellHeight) rather than
+ * poking `_core._renderService.dimensions` — keeps us on the public API surface
+ * so upgrades don't silently regress the fix.
+ *
+ * Returns the installed handler so the caller can remove it on dispose, or null
+ * when the terminal has not opened its DOM yet.
+ */
+export function installTerminalImeCandidateAnchor(terminal: Terminal): (() => void) | null {
+ if (!terminal.element || !terminal.textarea) {
+ return null
+ }
+ const screenElement = terminal.element.querySelector('.xterm-screen')
+ const textarea = terminal.textarea
+ const handler = (): void => {
+ if (!screenElement) {
+ return
+ }
+ const rect = screenElement.getBoundingClientRect()
+ const cellWidth = rect.width / terminal.cols
+ const cellHeight = rect.height / terminal.rows
+ if (!(cellWidth > 0) || !(cellHeight > 0)) {
+ return
+ }
+ const buf = terminal.buffer.active
+ // Why: Cursor Agent draws its prompt UI while leaving xterm's public cursor
+ // on a blank row, so the OS IME anchor needs the rendered prompt row instead.
+ const cursorAgentAnchor = resolveCursorAgentImeAnchor({
+ buffer: buf,
+ rows: terminal.rows,
+ cols: terminal.cols,
+ cursorX: buf.cursorX,
+ cursorY: buf.cursorY
+ })
+ const anchor = cursorAgentAnchor ?? {
+ row: buf.cursorY,
+ column: Math.min(buf.cursorX, terminal.cols - 1)
+ }
+ const applyAnchor = (): void => {
+ textarea.style.top = `${anchor.row * cellHeight}px`
+ textarea.style.left = `${anchor.column * cellWidth}px`
+ }
+ applyAnchor()
+ if (cursorAgentAnchor) {
+ window.setTimeout(() => {
+ if (textarea.isConnected) {
+ applyAnchor()
+ }
+ }, 0)
+ }
+ }
+ terminal.element.addEventListener('compositionstart', handler)
+ terminal.element.addEventListener('compositionupdate', handler)
+ return handler
+}
diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts
index 549defbb3a5..5dd0349507c 100644
--- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts
+++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts
@@ -56,10 +56,15 @@ export function buildWindowsPtyCompatibilityOptions(
if (!isLocalNativeWindowsConpty(context)) {
return {}
}
- const buildNumber = parseWindowsBuildNumber(context.osRelease)
- return {
- windowsPty: buildXtermWindowsPtyOptions(buildNumber)
- }
+ return buildLocalConptyTerminalOptions(context.osRelease)
+}
+
+/** ConPTY backend options for a pane already known to be local native Windows —
+ * the dashboard preview resolves that verdict upstream and reuses this. */
+export function buildLocalConptyTerminalOptions(
+ osRelease: string | null | undefined
+): Partial {
+ return { windowsPty: buildXtermWindowsPtyOptions(parseWindowsBuildNumber(osRelease)) }
}
/** Mirror of main's effectiveShellOverride fold (pty.ts spawn handlers): a
diff --git a/src/shared/dashboard-snapshot.ts b/src/shared/dashboard-snapshot.ts
index 5b70bf35802..043786e57c2 100644
--- a/src/shared/dashboard-snapshot.ts
+++ b/src/shared/dashboard-snapshot.ts
@@ -85,6 +85,29 @@ export type DashboardCard = {
/** The tab's conversation name, resolved exactly as the sidebar's agent rows
* resolve it. Undefined when no usable name exists (status-only titles). */
conversationName?: string
+ /** Host-dependent input facts the preview terminal needs to encode keys the
+ * way this agent's real pane does. Null when the card has no live pty. Only
+ * the main renderer owns the store these derive from, so they ride the
+ * snapshot to reach the pop-out. */
+ terminalInput?: DashboardCardTerminalInput
+}
+
+/**
+ * Per-pty input contract shared by a pane and its dashboard preview. Byte
+ * protocols follow the PTY's execution host, not the client OS — they differ
+ * for a macOS client driving a Windows runtime.
+ */
+export type DashboardCardTerminalInput = {
+ /** Platform executing the pty; picks the host-side byte encodings. */
+ hostPlatform: NodeJS.Platform
+ /** Local native Windows ConPTY, where PSReadLine binds Ctrl+←/→ itself. */
+ localWindowsConpty: boolean
+ /** OS release of a local Windows client, for xterm's ConPTY wrap-marker compat. */
+ osRelease?: string
+ /** Shift+Enter encoding resolved from this pane's agent evidence. */
+ windowsShiftEnterEncoding: 'alt-enter' | 'csi-u'
+ /** False withholds the kitty (CSI-u) advertisement, as ConPTY panes do. */
+ kittyKeyboardAdvertised: boolean
}
export type DashboardFilterOption = {
From 9cf31bdc00b71ad89b42684b3603db6938218c25 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 28 Jul 2026 23:08:55 -0700
Subject: [PATCH 15/67] perf(vault): stop rebuilding session maps on every
worktree switch (#11303)
Switching worktrees rebuilt two ~500-entry maps in the Agent Session
History panel because their memo deps included the active repo/worktree,
which the maps never read; the worktree map also rebuilt ~530 path
candidates (and re-normalized every root) per session, ~255k
isPathInsideOrEqual calls per switch.
- Drop activeRepo/activeWorktree from the sessionProjectById memo via
buildAiVaultSessionProjectById, and activeWorktreeId from
useAiVaultSessionWorktreeMap; 'current' is now stamped per row at read
time (withAiVaultCurrentWorktreeStatus), so switches reuse both maps.
- Hoist candidate building out of the per-session loop and precompute a
normalized-root matcher per candidate, so map rebuilds on data changes
are O(sessions + roots) normalizations instead of O(sessions x roots).
NFC folding from #10841 is untouched; non-ASCII (NFD/CJK) matching is
covered by new tests. Warm switch with the panel on All/500 drops
425ms -> 125ms on the full-scale rig (cold 491ms -> 193ms); panel-closed
switches are unchanged.
---
.../components/right-sidebar/AiVaultPanel.tsx | 63 +++---
.../ai-vault-session-projects.test.ts | 53 ++++-
.../ai-vault-session-projects.ts | 111 +++++++----
.../ai-vault-session-worktree-map.test.tsx | 187 ++++++++++++++++++
.../ai-vault-session-worktree.ts | 153 ++++++++------
5 files changed, 439 insertions(+), 128 deletions(-)
create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx
diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx
index c7a0479c39b..048ba89252e 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx
@@ -21,13 +21,19 @@ import {
normalizeAiVaultScopeForContext
} from './ai-vault-scope-state'
import { countAiVaultViewAdjustments } from './ai-vault-view-defaults'
-import { buildAiVaultProjectContext } from './ai-vault-session-projects'
+import {
+ buildAiVaultProjectContext,
+ buildAiVaultSessionProjectById
+} from './ai-vault-session-projects'
import {
resolveAiVaultSessionResumeActions,
resolveAiVaultSessionResumeState
} from './ai-vault-session-resume'
import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-actions'
-import { useAiVaultSessionWorktreeMap } from './ai-vault-session-worktree'
+import {
+ useAiVaultSessionWorktreeMap,
+ withAiVaultCurrentWorktreeStatus
+} from './ai-vault-session-worktree'
import { openAiVaultSessionLogInOrca } from './ai-vault-session-log-open'
import { useAiVaultOriginalPaneActions } from './ai-vault-original-pane-actions'
import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types'
@@ -136,27 +142,36 @@ export default function AiVaultPanel(): React.JSX.Element {
scopePaths,
executionHostScope
)
+ // Deliberately blind to the active repo/worktree: rebuilding these ~500-entry
+ // maps on every worktree switch is what made switching visibly slow (#10841 era).
const sessionProjectById = useMemo(
() =>
- buildAiVaultProjectContext({
+ buildAiVaultSessionProjectById({
repos,
worktrees: allWorktrees,
projectHostSetupProjection,
- activeRepo,
- activeWorktree,
sessions
- }).sessionProjectById,
- [activeRepo, activeWorktree, allWorktrees, projectHostSetupProjection, repos, sessions]
+ }),
+ [allWorktrees, projectHostSetupProjection, repos, sessions]
)
const sessionWorktreeById = useAiVaultSessionWorktreeMap({
sessions,
repos,
- worktrees: allWorktrees,
- activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null
+ worktrees: allWorktrees
})
+ const effectiveActiveWorktreeId = activeWorktreeId ?? activeWorktree?.id ?? null
+ // `current` is stamped per row at read time so the map above stays cached.
+ const getSessionWorktreeInfo = useCallback(
+ (session: AiVaultSession) =>
+ withAiVaultCurrentWorktreeStatus(
+ sessionWorktreeById.get(session.id) ?? null,
+ effectiveActiveWorktreeId
+ ),
+ [effectiveActiveWorktreeId, sessionWorktreeById]
+ )
const launchActions = useAiVaultSessionLaunchActions({
activeWorktree: activeWorktree ?? null,
- activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null,
+ activeWorktreeId: effectiveActiveWorktreeId,
targetState: resumeTargetState,
agentCmdOverrides
})
@@ -242,20 +257,13 @@ export default function AiVaultPanel(): React.JSX.Element {
resolveAiVaultSessionResumeState({
sessionFilePath: session.filePath,
sessionExecutionHostId: session.executionHostId,
- worktreeInfo: sessionWorktreeById.get(session.id) ?? null,
- activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null,
+ worktreeInfo: getSessionWorktreeInfo(session),
+ activeWorktreeId: effectiveActiveWorktreeId,
worktrees: allWorktrees,
repos,
targetState: resumeTargetState
}),
- [
- activeWorktree?.id,
- activeWorktreeId,
- allWorktrees,
- repos,
- resumeTargetState,
- sessionWorktreeById
- ]
+ [allWorktrees, effectiveActiveWorktreeId, getSessionWorktreeInfo, repos, resumeTargetState]
)
const getSessionResumeActions = useCallback(
@@ -263,20 +271,13 @@ export default function AiVaultPanel(): React.JSX.Element {
resolveAiVaultSessionResumeActions({
sessionFilePath: session.filePath,
sessionExecutionHostId: session.executionHostId,
- worktreeInfo: sessionWorktreeById.get(session.id) ?? null,
- activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null,
+ worktreeInfo: getSessionWorktreeInfo(session),
+ activeWorktreeId: effectiveActiveWorktreeId,
worktrees: allWorktrees,
repos,
targetState: resumeTargetState
}),
- [
- activeWorktree?.id,
- activeWorktreeId,
- allWorktrees,
- repos,
- resumeTargetState,
- sessionWorktreeById
- ]
+ [allWorktrees, effectiveActiveWorktreeId, getSessionWorktreeInfo, repos, resumeTargetState]
)
const handleScopeChange = useCallback((nextScope: AiVaultScope) => {
@@ -355,7 +356,7 @@ export default function AiVaultPanel(): React.JSX.Element {
getSessionResumeActions={getSessionResumeActions}
getOriginalPaneTarget={getOriginalPaneTarget}
getSessionLiveState={getSessionLiveState}
- getWorktreeInfo={(session) => sessionWorktreeById.get(session.id) ?? null}
+ getWorktreeInfo={getSessionWorktreeInfo}
onToggleGroup={toggleGroup}
onJumpToOriginalPane={jumpToOriginalPane}
onJumpToWorktree={jumpToWorktree}
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts
index d622af80803..e9d693d7dc0 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts
@@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest'
import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import type { Project, ProjectHostSetup, Repo, Worktree } from '../../../../shared/types'
-import { buildAiVaultProjectContext, toAiVaultProjectKey } from './ai-vault-session-projects'
+import {
+ buildAiVaultProjectContext,
+ buildAiVaultSessionProjectById,
+ toAiVaultProjectKey
+} from './ai-vault-session-projects'
const baseSession: AiVaultSession = {
id: 'claude:1',
@@ -446,6 +450,53 @@ describe('buildAiVaultProjectContext', () => {
})
})
+describe('buildAiVaultSessionProjectById', () => {
+ it('produces the exact context map without reading the active repo/worktree', () => {
+ const repoA = makeRepo({ id: 'repo-a', displayName: 'Alpha', path: '/Users/ada/alpha' })
+ const repoB = makeRepo({ id: 'repo-b', displayName: 'Beta', path: '/Users/ada/beta' })
+ const worktreeA = makeWorktree({ id: 'wt-a', repoId: repoA.id, path: '/Users/ada/alpha' })
+ const worktreeB = makeWorktree({ id: 'wt-b', repoId: repoB.id, path: '/Users/ada/beta' })
+ const shared = {
+ repos: [repoA, repoB],
+ worktrees: [worktreeA, worktreeB],
+ projectHostSetupProjection: makeProjection({}),
+ sessions: [
+ makeSession({ id: 'claude:a', cwd: '/Users/ada/alpha/src' }),
+ makeSession({ id: 'claude:b', cwd: '/Users/ada/beta' }),
+ makeSession({ id: 'claude:none', cwd: '/Users/ada/elsewhere' })
+ ]
+ }
+
+ const standalone = buildAiVaultSessionProjectById(shared)
+
+ // The active repo/worktree must never leak into attribution.
+ for (const [activeRepo, activeWorktree] of [
+ [repoA, worktreeA],
+ [repoB, worktreeB],
+ [null, null]
+ ] as const) {
+ expect(standalone).toEqual(
+ buildAiVaultProjectContext({ ...shared, activeRepo, activeWorktree }).sessionProjectById
+ )
+ }
+ })
+
+ it('attributes non-ASCII cwds across unicode normalization forms', () => {
+ // NFC worktree path vs NFD session cwd — both spell /Users/ada/café.
+ const repo = makeRepo({ id: 'repo-1', displayName: 'Café', path: '/Users/ada/caf\u00e9' })
+ const worktree = makeWorktree({ id: 'wt-1', repoId: repo.id, path: '/Users/ada/caf\u00e9' })
+
+ const map = buildAiVaultSessionProjectById({
+ repos: [repo],
+ worktrees: [worktree],
+ projectHostSetupProjection: makeProjection({}),
+ sessions: [makeSession({ id: 'claude:nfd', cwd: '/Users/ada/cafe\u0301/src' })]
+ })
+
+ expect(map.get('claude:nfd')).toMatchObject({ kind: 'repo', key: 'repo:repo-1' })
+ })
+})
+
function makeSession(overrides: Partial): AiVaultSession {
return { ...baseSession, ...overrides }
}
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts
index f9f13dc9c7c..be78edf4af2 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts
@@ -8,7 +8,7 @@ import type { ProjectHostSetupProjection } from '../../../../shared/project-host
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types'
import {
- isPathInsideOrEqual,
+ createNormalizedPathInsideOrEqualMatcher,
normalizeRuntimePathForComparison,
normalizeRuntimePathSeparators
} from '../../../../shared/cross-platform-path'
@@ -31,6 +31,8 @@ type SessionProjectCandidate = {
hostKey: ExecutionHostId
projectId: string | null
repoId: string | null
+ // Precomputed so a 500-session pass doesn't re-normalize every root per session.
+ ownsNormalizedCwd: (normalizedCwd: string) => boolean
}
type ProjectResolverArgs = {
@@ -50,6 +52,31 @@ export function buildAiVaultProjectContext({
activeWorktree,
sessions
}: ProjectResolverArgs): AiVaultProjectContext {
+ const setupByRepoId = buildSetupByRepoId(projectHostSetupProjection.setups)
+
+ return {
+ activeProjectKey: resolveActiveProjectKey(activeRepo, activeWorktree, setupByRepoId),
+ activeRepoId: activeRepo?.id ?? activeWorktree?.repoId ?? null,
+ projectLabelByKey: buildProjectLabelByKey(repos, projectHostSetupProjection),
+ sessionProjectById: buildAiVaultSessionProjectById({
+ repos,
+ worktrees,
+ projectHostSetupProjection,
+ sessions
+ })
+ }
+}
+
+/**
+ * Session→project attribution never reads the active repo/worktree — exposed
+ * separately so memoizing callers don't rebuild the map on worktree switches.
+ */
+export function buildAiVaultSessionProjectById({
+ repos,
+ worktrees,
+ projectHostSetupProjection,
+ sessions
+}: Omit): Map {
const repoById = new Map(repos.map((repo) => [repo.id, repo]))
const setupByRepoId = buildSetupByRepoId(projectHostSetupProjection.setups)
const projectLabelByKey = buildProjectLabelByKey(repos, projectHostSetupProjection)
@@ -60,20 +87,13 @@ export function buildAiVaultProjectContext({
setupByRepoId
)
const sessionProjectById = new Map()
-
for (const session of sessions) {
sessionProjectById.set(
session.id,
resolveSessionProject(session, candidates, projectLabelByKey)
)
}
-
- return {
- activeProjectKey: resolveActiveProjectKey(activeRepo, activeWorktree, setupByRepoId),
- activeRepoId: activeRepo?.id ?? activeWorktree?.repoId ?? null,
- projectLabelByKey,
- sessionProjectById
- }
+ return sessionProjectById
}
export function toAiVaultProjectKey(
@@ -145,17 +165,19 @@ function buildProjectCandidates(
}
const repo = repoById.get(worktree.repoId)
const setup = setupByRepoId.get(worktree.repoId)
- candidates.push({
- source: 'worktree',
- normalizedPath: normalizeRuntimePathForComparison(worktree.path),
- hostKey: resolveCandidateHostId(
- worktree.hostId,
- setup?.hostId,
- repo ? getRepoExecutionHostId(repo) : null
- ),
- projectId: worktree.projectId ?? setup?.projectId ?? null,
- repoId: worktree.repoId
- })
+ candidates.push(
+ makeProjectCandidate({
+ source: 'worktree',
+ path: worktree.path,
+ hostKey: resolveCandidateHostId(
+ worktree.hostId,
+ setup?.hostId,
+ repo ? getRepoExecutionHostId(repo) : null
+ ),
+ projectId: worktree.projectId ?? setup?.projectId ?? null,
+ repoId: worktree.repoId
+ })
+ )
}
for (const setup of projection.setups) {
@@ -167,13 +189,15 @@ function buildProjectCandidates(
if (!hasCandidatePath(setup.path)) {
continue
}
- candidates.push({
- source: 'setup',
- normalizedPath: normalizeRuntimePathForComparison(setup.path),
- hostKey: resolveCandidateHostId(setup.hostId, getRepoExecutionHostId(setup)),
- projectId: setup.projectId,
- repoId: setup.repoId || null
- })
+ candidates.push(
+ makeProjectCandidate({
+ source: 'setup',
+ path: setup.path,
+ hostKey: resolveCandidateHostId(setup.hostId, getRepoExecutionHostId(setup)),
+ projectId: setup.projectId,
+ repoId: setup.repoId || null
+ })
+ )
}
for (const repo of repoById.values()) {
@@ -183,18 +207,32 @@ function buildProjectCandidates(
if (!hasCandidatePath(repo.path)) {
continue
}
- candidates.push({
- source: 'setup',
- normalizedPath: normalizeRuntimePathForComparison(repo.path),
- hostKey: getRepoExecutionHostId(repo),
- projectId: null,
- repoId: repo.id
- })
+ candidates.push(
+ makeProjectCandidate({
+ source: 'setup',
+ path: repo.path,
+ hostKey: getRepoExecutionHostId(repo),
+ projectId: null,
+ repoId: repo.id
+ })
+ )
}
return candidates
}
+function makeProjectCandidate(
+ fields: Omit & { path: string }
+): SessionProjectCandidate {
+ const { path, ...rest } = fields
+ const normalizedPath = normalizeRuntimePathForComparison(path)
+ return {
+ ...rest,
+ normalizedPath,
+ ownsNormalizedCwd: createNormalizedPathInsideOrEqualMatcher(normalizedPath)
+ }
+}
+
function resolveCandidateHostId(
...values: readonly (string | null | undefined)[]
): ExecutionHostId {
@@ -221,9 +259,8 @@ function resolveSessionProject(
return { kind: 'unknown', key: 'unknown', label: '' }
}
- const matches = candidates.filter((candidate) =>
- isPathInsideOrEqual(candidate.normalizedPath, cwd)
- )
+ const normalizedCwd = normalizeRuntimePathForComparison(cwd)
+ const matches = candidates.filter((candidate) => candidate.ownsNormalizedCwd(normalizedCwd))
const sessionHostId = normalizeExecutionHostId(session.executionHostId)
const hostMatches = sessionHostId
? matches.filter((candidate) => candidate.hostKey === sessionHostId)
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx
new file mode 100644
index 00000000000..aae0ad8d32a
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx
@@ -0,0 +1,187 @@
+// @vitest-environment happy-dom
+import { renderHook } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import type { AiVaultSession } from '../../../../shared/ai-vault-types'
+import type { Repo, Worktree } from '../../../../shared/types'
+import {
+ resolveAiVaultSessionWorktreeDisplay,
+ useAiVaultSessionWorktreeMap,
+ withAiVaultCurrentWorktreeStatus
+} from './ai-vault-session-worktree'
+
+function makeSession(overrides: Partial): AiVaultSession {
+ return {
+ id: 'codex:session-1',
+ executionHostId: 'local',
+ agent: 'codex',
+ sessionId: 'session-1',
+ title: 'Find the pane',
+ cwd: '/repo/orca/src',
+ branch: null,
+ model: null,
+ filePath: '/home/ada/.codex/session-1.jsonl',
+ codexHome: null,
+ createdAt: null,
+ updatedAt: '2026-06-24T10:00:00.000Z',
+ modifiedAt: '2026-06-24T10:00:00.000Z',
+ messageCount: 2,
+ totalTokens: 42,
+ previewMessages: [],
+ queuedMessageCount: 0,
+ subagentTranscriptCount: 0,
+ resumeCommand: "codex resume 'session-1'",
+ subagent: null,
+ ...overrides
+ }
+}
+
+function makeWorktree(overrides: Partial = {}): Worktree {
+ return {
+ id: 'repo-1::/repo/orca',
+ repoId: 'repo-1',
+ displayName: 'orca',
+ path: '/repo/orca',
+ head: 'abc123',
+ branch: 'main',
+ isBare: false,
+ comment: '',
+ linkedIssue: null,
+ linkedPR: null,
+ linkedLinearIssue: null,
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 0,
+ lastActivityAt: 1,
+ isMainWorktree: false,
+ ...overrides
+ }
+}
+
+function makeRepo(overrides: Partial = {}): Repo {
+ return {
+ id: 'repo-1',
+ path: '/repo/orca',
+ displayName: 'orca',
+ badgeColor: '#737373',
+ addedAt: 1,
+ connectionId: null,
+ executionHostId: 'local',
+ ...overrides
+ }
+}
+
+const worktreeA = makeWorktree({ id: 'repo-1::/repo/alpha', path: '/repo/alpha' })
+const worktreeB = makeWorktree({ id: 'repo-1::/repo/beta', path: '/repo/beta' })
+const sessionInA = makeSession({ id: 'codex:in-a', cwd: '/repo/alpha/src' })
+const sessionInB = makeSession({ id: 'codex:in-b', cwd: '/repo/beta' })
+const sessionUnmatched = makeSession({ id: 'codex:lost', cwd: '/elsewhere/deep' })
+const repos = [makeRepo()]
+const worktrees = [worktreeA, worktreeB]
+const sessions = [sessionInA, sessionInB, sessionUnmatched]
+
+describe('useAiVaultSessionWorktreeMap', () => {
+ it('keeps the map identity across worktree switches while the current flag moves', () => {
+ // Mirrors the panel wiring: cached map + per-row current stamping.
+ const { result, rerender } = renderHook(
+ ({ activeWorktreeId }: { activeWorktreeId: string | null }) => {
+ const map = useAiVaultSessionWorktreeMap({ sessions, repos, worktrees })
+ return {
+ map,
+ infoFor: (sessionId: string) =>
+ withAiVaultCurrentWorktreeStatus(map.get(sessionId) ?? null, activeWorktreeId)
+ }
+ },
+ { initialProps: { activeWorktreeId: worktreeA.id as string | null } }
+ )
+
+ const firstMap = result.current.map
+ expect(result.current.infoFor(sessionInA.id)?.status).toBe('current')
+ expect(result.current.infoFor(sessionInB.id)?.status).toBe('active')
+
+ rerender({ activeWorktreeId: worktreeB.id })
+
+ // The switch must not rebuild the 500-session map — only the stamp moves.
+ expect(result.current.map).toBe(firstMap)
+ expect(result.current.infoFor(sessionInA.id)?.status).toBe('active')
+ expect(result.current.infoFor(sessionInB.id)?.status).toBe('current')
+ })
+
+ it('render-time stamping matches per-session resolution for every status case', () => {
+ const sshWorktree = makeWorktree({
+ id: 'repo-ssh::/srv/orca',
+ repoId: 'repo-ssh',
+ displayName: 'ssh',
+ path: '/srv/orca',
+ hostId: 'ssh:target-1'
+ })
+ const archivedWorktree = makeWorktree({
+ id: 'repo-1::/repo/attic',
+ path: '/repo/attic',
+ isArchived: true
+ })
+ const allWorktrees = [...worktrees, sshWorktree, archivedWorktree]
+ const allRepos = [...repos, makeRepo({ id: 'repo-ssh', path: '/srv/orca' })]
+ const allSessions = [
+ sessionInA, // active worktree
+ sessionInB, // non-active worktree
+ sessionUnmatched, // no worktree match
+ makeSession({ id: 'codex:ssh', cwd: '/srv/orca/src', executionHostId: 'ssh:target-1' }),
+ makeSession({ id: 'codex:attic', cwd: '/repo/attic' }),
+ makeSession({ id: 'codex:no-cwd', cwd: null, branch: 'feature/x' })
+ ]
+
+ const { result } = renderHook(() =>
+ useAiVaultSessionWorktreeMap({
+ sessions: allSessions,
+ repos: allRepos,
+ worktrees: allWorktrees
+ })
+ )
+
+ for (const activeWorktreeId of [worktreeA.id, sshWorktree.id, archivedWorktree.id, null]) {
+ for (const session of allSessions) {
+ expect(
+ withAiVaultCurrentWorktreeStatus(result.current.get(session.id) ?? null, activeWorktreeId)
+ ).toEqual(
+ resolveAiVaultSessionWorktreeDisplay({
+ session,
+ repos: allRepos,
+ worktrees: allWorktrees,
+ activeWorktreeId
+ })
+ )
+ }
+ }
+ })
+
+ it('matches non-ASCII cwds regardless of unicode normalization form', () => {
+ // macOS file pickers yield NFD while agents record NFC cwds (#10832) —
+ // explicit escapes so tooling can't silently re-normalize the fixture.
+ const nfcCafe = makeWorktree({ id: 'repo-1::/repo/caf\u00e9', path: '/repo/caf\u00e9' })
+ const cjk = makeWorktree({ id: 'repo-1::/repo/作業区', path: '/repo/作業区' })
+ const nfdSession = makeSession({ id: 'codex:nfd', cwd: '/repo/cafe\u0301/src' })
+ const cjkSession = makeSession({ id: 'codex:cjk', cwd: '/repo/作業区/src' })
+
+ const { result } = renderHook(() =>
+ useAiVaultSessionWorktreeMap({
+ sessions: [nfdSession, cjkSession],
+ repos,
+ worktrees: [nfcCafe, cjk]
+ })
+ )
+
+ expect(result.current.get(nfdSession.id)).toMatchObject({
+ status: 'active',
+ worktreeId: nfcCafe.id
+ })
+ expect(
+ withAiVaultCurrentWorktreeStatus(result.current.get(nfdSession.id) ?? null, nfcCafe.id)
+ ?.status
+ ).toBe('current')
+ expect(result.current.get(cjkSession.id)).toMatchObject({
+ status: 'active',
+ worktreeId: cjk.id
+ })
+ })
+})
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts
index 670e2deecc1..d03aedb809e 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts
@@ -8,7 +8,7 @@ import {
type ExecutionHostId
} from '../../../../shared/execution-host'
import {
- isPathInsideOrEqual,
+ createNormalizedPathInsideOrEqualMatcher,
isRuntimePathAbsolute,
normalizeRuntimePathForComparison
} from '../../../../shared/cross-platform-path'
@@ -41,6 +41,9 @@ type WorktreeCandidate = {
hostId: ExecutionHostId
status: Exclude
source: 'current-path' | 'prior-path'
+ // Precomputed so a 500-session scan doesn't re-normalize ~500 roots per session.
+ ownsNormalizedCwd: (normalizedCwd: string) => boolean
+ normalizedPathLength: number
}
export function resolveAiVaultSessionWorktreeInfo({
@@ -54,17 +57,42 @@ export function resolveAiVaultSessionWorktreeInfo({
worktrees: readonly Worktree[]
activeWorktreeId: string | null
}): AiVaultSessionWorktreeInfo | null {
+ return withAiVaultCurrentWorktreeStatus(
+ resolveWorktreeInfoFromCandidates(session, buildWorktreeCandidates(worktrees, repos)),
+ activeWorktreeId
+ )
+}
+
+/**
+ * Stamps `status: 'current'` at read time so the session→worktree map itself
+ * never depends on the active worktree — switching worktrees must not rebuild it.
+ */
+export function withAiVaultCurrentWorktreeStatus(
+ worktreeInfo: AiVaultSessionWorktreeInfo | null,
+ activeWorktreeId: string | null
+): AiVaultSessionWorktreeInfo | null {
+ if (!worktreeInfo?.worktreeId || worktreeInfo.worktreeId !== activeWorktreeId) {
+ return worktreeInfo
+ }
+ return worktreeInfo.status === 'current' ? worktreeInfo : { ...worktreeInfo, status: 'current' }
+}
+
+function resolveWorktreeInfoFromCandidates(
+ session: AiVaultSession,
+ candidates: readonly WorktreeCandidate[]
+): AiVaultSessionWorktreeInfo | null {
if (!session.cwd) {
return null
}
const sessionHostId = normalizeExecutionHostId(session.executionHostId)
- const candidates = buildWorktreeCandidates(worktrees, repos)
- .filter((candidate) => isSessionInWorktreePath(candidate.path, session.cwd!))
+ const normalizedCwd = normalizeRuntimePathForComparison(session.cwd)
+ const matched = candidates
+ .filter((candidate) => candidate.ownsNormalizedCwd(normalizedCwd))
.filter((candidate) => !sessionHostId || candidate.hostId === sessionHostId)
.sort(compareWorktreeCandidates)
- const best = candidates[0]
+ const best = matched[0]
if (!best) {
return {
status: 'unavailable',
@@ -73,15 +101,8 @@ export function resolveAiVaultSessionWorktreeInfo({
}
}
- const status =
- best.worktree.id === activeWorktreeId
- ? 'current'
- : best.worktree.isArchived
- ? 'archived'
- : best.status
-
return {
- status,
+ status: best.status,
label: best.worktree.displayName || compactPathLabel(best.path),
path: best.path,
worktreeId: best.worktree.id
@@ -109,22 +130,35 @@ export function resolveAiVaultSessionWorktreeDisplay(args: {
worktrees: readonly Worktree[]
activeWorktreeId: string | null
}): AiVaultSessionWorktreeInfo | null {
- const resolved = resolveAiVaultSessionWorktreeInfo(args)
+ return withAiVaultCurrentWorktreeStatus(
+ resolveWorktreeDisplayFromCandidates(
+ args.session,
+ buildWorktreeCandidates(args.worktrees, args.repos ?? [])
+ ),
+ args.activeWorktreeId
+ )
+}
+
+function resolveWorktreeDisplayFromCandidates(
+ session: AiVaultSession,
+ candidates: readonly WorktreeCandidate[]
+): AiVaultSessionWorktreeInfo | null {
+ const resolved = resolveWorktreeInfoFromCandidates(session, candidates)
if (resolved) {
return resolved
}
- const cwd = args.session.cwd?.trim()
+ const cwd = session.cwd?.trim()
if (cwd) {
return unavailableWorktreeInfo(cwd)
}
- const titlePath = extractWorktreePathFromSessionTitle(args.session.title)
+ const titlePath = extractWorktreePathFromSessionTitle(session.title)
if (titlePath) {
return unavailableWorktreeInfo(titlePath)
}
- const branch = args.session.branch?.trim()
+ const branch = session.branch?.trim()
if (branch) {
return {
status: 'unavailable',
@@ -136,32 +170,31 @@ export function resolveAiVaultSessionWorktreeDisplay(args: {
return null
}
+/**
+ * Deliberately unaware of the active worktree: stamping `current` here would
+ * rebuild the whole map (candidates × sessions) on every worktree switch.
+ * Callers derive it per row via `withAiVaultCurrentWorktreeStatus`.
+ */
export function useAiVaultSessionWorktreeMap({
sessions,
repos = [],
- worktrees,
- activeWorktreeId
+ worktrees
}: {
sessions: readonly AiVaultSession[]
repos?: readonly Pick[]
worktrees: readonly Worktree[]
- activeWorktreeId: string | null
}): ReadonlyMap {
- return useMemo(
- () =>
- new Map(
- sessions.flatMap((session) => {
- const worktreeInfo = resolveAiVaultSessionWorktreeDisplay({
- session,
- repos,
- worktrees,
- activeWorktreeId
- })
- return worktreeInfo ? [[session.id, worktreeInfo] as const] : []
- })
- ),
- [activeWorktreeId, repos, sessions, worktrees]
- )
+ return useMemo(() => {
+ // Hoisted out of the per-session loop: candidates and their normalized
+ // roots are session-independent.
+ const candidates = buildWorktreeCandidates(worktrees, repos)
+ return new Map(
+ sessions.flatMap((session) => {
+ const worktreeInfo = resolveWorktreeDisplayFromCandidates(session, candidates)
+ return worktreeInfo ? [[session.id, worktreeInfo] as const] : []
+ })
+ )
+ }, [repos, sessions, worktrees])
}
function buildWorktreeCandidates(
@@ -176,48 +209,50 @@ function buildWorktreeCandidates(
normalizeExecutionHostId(worktree.hostId) ??
(repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID)
if (hasUsablePath(worktree.path)) {
- candidates.push({
- worktree,
- path: worktree.path,
- hostId,
- status: worktree.isArchived ? 'archived' : 'active',
- source: 'current-path'
- })
+ candidates.push(makeWorktreeCandidate(worktree, worktree.path, hostId, 'current-path'))
}
for (const priorWorktreeId of worktree.priorWorktreeIds ?? []) {
const parsed = splitWorktreeIdForFilesystem(priorWorktreeId)
if (!parsed || parsed.repoId !== worktree.repoId || !hasUsablePath(parsed.worktreePath)) {
continue
}
- candidates.push({
- worktree,
- path: parsed.worktreePath,
- hostId,
- status: worktree.isArchived ? 'archived' : 'active',
- source: 'prior-path'
- })
+ candidates.push(makeWorktreeCandidate(worktree, parsed.worktreePath, hostId, 'prior-path'))
}
}
return candidates
}
+function makeWorktreeCandidate(
+ worktree: Worktree,
+ path: string,
+ hostId: ExecutionHostId,
+ source: WorktreeCandidate['source']
+): WorktreeCandidate {
+ const ownsCwd = createNormalizedPathInsideOrEqualMatcher(path)
+ // A WSL UNC root also owns sessions recorded under its Linux-native cwd.
+ const wslPath = parseWslUncPath(path)
+ const ownsCwdViaWslAlias = wslPath
+ ? createNormalizedPathInsideOrEqualMatcher(wslPath.linuxPath)
+ : null
+ return {
+ worktree,
+ path,
+ hostId,
+ status: worktree.isArchived ? 'archived' : 'active',
+ source,
+ ownsNormalizedCwd: (normalizedCwd) =>
+ ownsCwd(normalizedCwd) || (ownsCwdViaWslAlias?.(normalizedCwd) ?? false),
+ normalizedPathLength: normalizeRuntimePathForComparison(path).length
+ }
+}
+
function hasUsablePath(pathValue: string): boolean {
const trimmed = pathValue.trim()
return Boolean(trimmed && isRuntimePathAbsolute(trimmed))
}
-function isSessionInWorktreePath(worktreePath: string, sessionCwd: string): boolean {
- if (isPathInsideOrEqual(worktreePath, sessionCwd)) {
- return true
- }
- const wslPath = parseWslUncPath(worktreePath)
- return wslPath ? isPathInsideOrEqual(wslPath.linuxPath, sessionCwd) : false
-}
-
function compareWorktreeCandidates(left: WorktreeCandidate, right: WorktreeCandidate): number {
- const lengthDifference =
- normalizeRuntimePathForComparison(right.path).length -
- normalizeRuntimePathForComparison(left.path).length
+ const lengthDifference = right.normalizedPathLength - left.normalizedPathLength
if (lengthDifference !== 0) {
return lengthDifference
}
From c5102e1262ca2550fba84c807a6e452484a700ce Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Tue, 28 Jul 2026 23:45:05 -0700
Subject: [PATCH 16/67] test(vault): guard the workspace-switch regression
#11303 fixed (#11311)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
#11303 removed the O(sessions x roots) path normalization from the session
worktree map, but nothing fails if that shape comes back. Adds the two
checks that were missing, plus the tool that would have caught it.
- timing guard: 1200 worktrees x 400 sessions must build in <150ms.
Verified fail-first — restoring the pre-#11303 per-session
buildWorktreeCandidates call takes it to 238ms; it is ~15ms as merged.
- path boundary: '/repo/alpha-sibling' must not be attributed to
'/repo/alpha'. Hoisting the root normalization out of the loop must not
degrade containment into a bare startsWith.
Also adds tools/benchmarks/workspace-switch-paint-latency.mjs, which
attaches over CDP and measures first-paint-after-click and max frame gap.
The existing worktree-switch-responsiveness.spec.ts only times the
synchronous click task, which stays ~1ms because the highlight is a direct
DOM mutation — that is why a ~1s stall could ship without tripping a
budget. On the affected build it read maxFrameGap p50=973ms.
---
.../ai-vault-session-worktree-map.test.tsx | 49 +++
.../workspace-switch-paint-latency.mjs | 398 ++++++++++++++++++
2 files changed, 447 insertions(+)
create mode 100644 tools/benchmarks/workspace-switch-paint-latency.mjs
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx
index aae0ad8d32a..90ea7cd900c 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx
@@ -184,4 +184,53 @@ describe('useAiVaultSessionWorktreeMap', () => {
worktreeId: cjk.id
})
})
+
+ it('does not attribute a session to a workspace that merely shares a path prefix', () => {
+ // Guards the candidate matcher's boundary: hoisting the root normalization
+ // out of the loop must not degrade containment into a bare startsWith, or
+ // '/repo/alpha-sibling' would be swallowed by '/repo/alpha'.
+ const sibling = makeWorktree({
+ id: 'repo-1::/repo/alpha-sibling',
+ path: '/repo/alpha-sibling'
+ })
+ const session = makeSession({ id: 'codex:sibling', cwd: '/repo/alpha-sibling/src' })
+
+ const { result } = renderHook(() =>
+ useAiVaultSessionWorktreeMap({
+ sessions: [session],
+ repos,
+ worktrees: [worktreeA, sibling]
+ })
+ )
+
+ expect(result.current.get(session.id)).toMatchObject({ worktreeId: sibling.id })
+ })
+
+ it('builds the map at scale without re-normalizing paths per session', () => {
+ // Regression for the ~1s workspace-switch stall (#10841/#11303): the map
+ // used to rebuild and re-normalize every candidate root per session, making
+ // it O(sessions x roots) on path normalization. Times the real fan-out
+ // rather than counting calls, so it fails on any return to that shape.
+ const manyWorktrees = Array.from({ length: 1200 }, (_, i) =>
+ makeWorktree({ id: `repo-1::/repo/w${i}`, path: `/repo/w${i}` })
+ )
+ const manySessions = Array.from({ length: 400 }, (_, i) =>
+ makeSession({ id: `codex:s${i}`, cwd: `/repo/w${i % manyWorktrees.length}/src` })
+ )
+
+ const startedAt = performance.now()
+ const { result } = renderHook(() =>
+ useAiVaultSessionWorktreeMap({
+ sessions: manySessions,
+ repos,
+ worktrees: manyWorktrees
+ })
+ )
+ const elapsedMs = performance.now() - startedAt
+
+ expect(result.current.size).toBe(manySessions.length)
+ // Was ~350ms+ before the hoist and ~15ms after; the bound is loose enough
+ // to stay quiet on a slow CI box while still catching the quadratic shape.
+ expect(elapsedMs).toBeLessThan(150)
+ })
})
diff --git a/tools/benchmarks/workspace-switch-paint-latency.mjs b/tools/benchmarks/workspace-switch-paint-latency.mjs
new file mode 100644
index 00000000000..200c0053b8c
--- /dev/null
+++ b/tools/benchmarks/workspace-switch-paint-latency.mjs
@@ -0,0 +1,398 @@
+#!/usr/bin/env node
+/**
+ * Measures how long a workspace-card click takes to become VISIBLE.
+ *
+ * The existing e2e spec (tests/e2e/worktree-switch-responsiveness.spec.ts) only
+ * measures the synchronous click task. That stays fast even when the symptom is
+ * present: `markSidebarWorktreeActiveImmediately` mutates the DOM in the click
+ * handler, so the attribute flips in ~1ms. What the user sees is the next
+ * painted FRAME, and that frame is blocked when the async activation path jams
+ * the main thread. So the number that matters is first-paint-after-click.
+ *
+ * Attaches over CDP to an already-running dev app:
+ *
+ * ORCA_DEV_USER_DATA_PATH=... REMOTE_DEBUGGING_PORT=9455 pn dev
+ * node tools/benchmarks/workspace-switch-paint-latency.mjs --port 9455
+ *
+ * Exits non-zero when the median first-paint exceeds --budget (default 200ms),
+ * which makes it usable directly as a `git bisect run` predicate.
+ */
+
+import { writeFileSync, mkdirSync } from 'node:fs'
+import path from 'node:path'
+import { chromium } from '@stablyai/playwright-test'
+
+function parseArgs(argv) {
+ const args = {
+ port: 9455,
+ switches: 6,
+ budgetMs: 200,
+ settleMs: 2500,
+ out: null,
+ json: false
+ }
+ for (let i = 0; i < argv.length; i += 1) {
+ const [flag, inlineValue] = argv[i].split('=')
+ const value = inlineValue ?? argv[i + 1]
+ const consume = () => {
+ if (inlineValue === undefined) {
+ i += 1
+ }
+ }
+ switch (flag) {
+ case '--port':
+ args.port = Number(value)
+ consume()
+ break
+ case '--switches':
+ args.switches = Number(value)
+ consume()
+ break
+ case '--budget':
+ args.budgetMs = Number(value)
+ consume()
+ break
+ case '--settle':
+ args.settleMs = Number(value)
+ consume()
+ break
+ case '--out':
+ args.out = value
+ consume()
+ break
+ case '--json':
+ args.json = true
+ break
+ case '--help':
+ console.log(
+ 'Usage: workspace-switch-paint-latency.mjs [--port 9455] [--switches 6] [--budget 200] [--settle 2500] [--out file.json] [--json]'
+ )
+ process.exit(0)
+ break
+ default:
+ break
+ }
+ }
+ return args
+}
+
+const args = parseArgs(process.argv.slice(2))
+
+function quantile(sorted, q) {
+ if (sorted.length === 0) {
+ return null
+ }
+ const pos = (sorted.length - 1) * q
+ const lo = Math.floor(pos)
+ const hi = Math.ceil(pos)
+ if (lo === hi) {
+ return sorted[lo]
+ }
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo)
+}
+
+function summarize(values) {
+ const clean = values.filter((v) => typeof v === 'number' && Number.isFinite(v))
+ if (clean.length === 0) {
+ return null
+ }
+ const sorted = [...clean].sort((a, b) => a - b)
+ return {
+ n: sorted.length,
+ min: +sorted[0].toFixed(1),
+ p50: +quantile(sorted, 0.5).toFixed(1),
+ p90: +quantile(sorted, 0.9).toFixed(1),
+ max: +sorted.at(-1).toFixed(1)
+ }
+}
+
+/**
+ * Installed in the renderer before each click. Records, from the real
+ * pointerdown, every animation frame plus long tasks, so we can tell a jammed
+ * main thread (no frames for ~1s) apart from slow-but-smooth work.
+ */
+const PROBE_SOURCE = `(targetId) => {
+ const probe = {
+ t0: null,
+ targetId,
+ frames: [],
+ longTasks: [],
+ attrFlipMs: null,
+ storeCommitMs: null,
+ renderedCommitMs: null,
+ done: false
+ }
+ window.__switchProbe = probe
+
+ const surfaceOf = (id) => {
+ const row = [...document.querySelectorAll('[data-worktree-id]')].find(
+ (el) => el.dataset.worktreeId === id
+ )
+ return row ? row.querySelector('[data-worktree-card-surface]') : null
+ }
+ const isActive = (id) => {
+ const s = surfaceOf(id)
+ return Boolean(s && s.getAttribute('data-worktree-card-active'))
+ }
+ const renderedId = () =>
+ document
+ .querySelector('[data-rendered-active-worktree-id]')
+ ?.getAttribute('data-rendered-active-worktree-id') ?? null
+
+ try {
+ const po = new PerformanceObserver((list) => {
+ for (const e of list.getEntries()) {
+ if (probe.t0 === null) continue
+ probe.longTasks.push({
+ startMs: +(e.startTime - probe.t0).toFixed(1),
+ durationMs: +e.duration.toFixed(1)
+ })
+ }
+ })
+ po.observe({ entryTypes: ['longtask'] })
+ probe.observer = po
+ } catch {}
+
+ // Why: pointerdown is the earliest thing the user's click produces, so it is
+ // the honest zero point for "time until I saw something happen".
+ const onPointerDown = () => {
+ if (probe.t0 !== null) return
+ probe.t0 = performance.now()
+ const tick = () => {
+ const now = performance.now() - probe.t0
+ probe.frames.push(+now.toFixed(1))
+ if (probe.attrFlipMs === null && isActive(probe.targetId)) probe.attrFlipMs = +now.toFixed(1)
+ if (probe.storeCommitMs === null) {
+ const st = window.__store?.getState?.()
+ if (st && st.activeWorktreeId === probe.targetId) probe.storeCommitMs = +now.toFixed(1)
+ }
+ if (probe.renderedCommitMs === null && renderedId() === probe.targetId) {
+ probe.renderedCommitMs = +now.toFixed(1)
+ }
+ if (!probe.done) requestAnimationFrame(tick)
+ }
+ requestAnimationFrame(tick)
+ }
+ window.addEventListener('pointerdown', onPointerDown, { capture: true, once: true })
+ probe.cleanup = () => {
+ probe.done = true
+ try { probe.observer?.disconnect() } catch {}
+ window.removeEventListener('pointerdown', onPointerDown, { capture: true })
+ }
+ return true
+}`
+
+/** Press Escape while any full-screen overlay is intercepting pointer events. */
+async function dismissOverlays(page) {
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ const blocked = await page.evaluate(() =>
+ [...document.querySelectorAll('body *')].some((el) => {
+ const r = el.getBoundingClientRect()
+ if (r.width < window.innerWidth * 0.9 || r.height < window.innerHeight * 0.9) {
+ return false
+ }
+ const s = getComputedStyle(el)
+ return s.position === 'fixed' && s.pointerEvents !== 'none' && Number(s.zIndex) >= 40
+ })
+ )
+ if (!blocked) {
+ return
+ }
+ await page.keyboard.press('Escape')
+ await page.waitForTimeout(200)
+ }
+}
+
+async function main() {
+ const browser = await chromium.connectOverCDP(`http://127.0.0.1:${args.port}`)
+ const contexts = browser.contexts()
+ const pages = contexts.flatMap((c) => c.pages())
+ let page = null
+ for (const candidate of pages) {
+ const hasStore = await candidate
+ .evaluate(
+ () => Boolean(window.__store) && Boolean(document.querySelector('[data-worktree-id]'))
+ )
+ .catch(() => false)
+ if (hasStore) {
+ page = candidate
+ break
+ }
+ }
+ if (!page) {
+ throw new Error(
+ 'No renderer page with window.__store + a mounted sidebar. Is the app past startup, and is the sidebar open?'
+ )
+ }
+
+ // Normalize sidebar state so the same cards are reachable across commits.
+ // Why pin the sort: 'recent' reorders the list after every switch, which
+ // would move the card out from under the cursor mid-run.
+ const worktreeIds = await page.evaluate(() => {
+ const state = window.__store.getState()
+ state.setActiveView('terminal')
+ state.setSidebarOpen(true)
+ state.setGroupBy?.('none')
+ state.setSortBy?.('name')
+ state.setShowActiveOnly?.(false)
+ state.setFilterRepoIds?.([])
+ const viewportH = window.innerHeight
+ // Only cards fully inside the viewport are clickable: the list is
+ // virtualized, so off-screen rows are unmounted or positioned outside.
+ return [...document.querySelectorAll('[data-worktree-id]')]
+ .filter((el) => {
+ const surface = el.querySelector('[data-worktree-card-surface]')
+ if (!surface) {
+ return false
+ }
+ const r = surface.getBoundingClientRect()
+ return r.height > 0 && r.top >= 0 && r.bottom <= viewportH
+ })
+ .map((el) => el.dataset.worktreeId)
+ .filter((id, i, all) => id && all.indexOf(id) === i)
+ })
+
+ if (worktreeIds.length < 2) {
+ throw new Error(
+ `Need >=2 workspace cards visible in the viewport, found ${worktreeIds.length}. Is the sidebar open?`
+ )
+ }
+
+ // Alternate between two cards, neither of which is the one already active at
+ // start — clicking the active card short-circuits and measures nothing.
+ const activeAtStart = await page.evaluate(() => window.__store.getState().activeWorktreeId)
+ const pair = worktreeIds.filter((id) => id !== activeAtStart).slice(0, 2)
+ if (pair.length < 2) {
+ throw new Error('Need two visible non-active cards to alternate between')
+ }
+ console.error(`alternating between:\n ${pair[0]}\n ${pair[1]}`)
+
+ const samples = []
+ for (let i = 0; i < args.switches; i += 1) {
+ const targetId = pair[i % 2]
+
+ // Why: page.evaluate() treats a string as an expression to evaluate and
+ // drops the argument, so the probe has to be applied inline.
+ const surface = page
+ .locator(`[data-worktree-id="${targetId}"] [data-worktree-card-surface]`)
+ .first()
+
+ // Why: a transient popover (`fixed inset-0 z-50`) swallows the click, and
+ // the run would then measure a click that never reached the card.
+ await dismissOverlays(page)
+
+ await page.evaluate(`(${PROBE_SOURCE})(${JSON.stringify(targetId)})`)
+
+ // Real CDP input events, not element.click(): the click path has
+ // pointer-drag suppression that a synthetic click would bypass. Going
+ // through the locator also re-resolves position at click time, which
+ // matters because the virtualized list reorders between switches.
+ try {
+ await surface.click({ timeout: 10_000 })
+ } catch (error) {
+ console.error(`skip switch ${i + 1}: ${String(error).split('\n')[0]}`)
+ continue
+ }
+
+ await page.waitForTimeout(args.settleMs)
+
+ const sample = await page.evaluate(() => {
+ const p = window.__switchProbe
+ p.cleanup?.()
+ const frames = p.frames
+ let maxGap = 0
+ let prev = 0
+ for (const f of frames) {
+ maxGap = Math.max(maxGap, f - prev)
+ prev = f
+ }
+ return {
+ firstPaintMs: frames.length ? frames[0] : null,
+ maxFrameGapMs: +maxGap.toFixed(1),
+ frameCount: frames.length,
+ attrFlipMs: p.attrFlipMs,
+ storeCommitMs: p.storeCommitMs,
+ renderedCommitMs: p.renderedCommitMs,
+ longTaskCount: p.longTasks.length,
+ longTaskTotalMs: +p.longTasks.reduce((a, t) => a + t.durationMs, 0).toFixed(1),
+ worstLongTaskMs: p.longTasks.reduce((a, t) => Math.max(a, t.durationMs), 0)
+ }
+ })
+ sample.targetId = targetId
+ // Why: a click that never activated would report a fast "first paint" and
+ // silently make every commit look good during a bisect.
+ if (sample.attrFlipMs === null && sample.renderedCommitMs === null) {
+ throw new Error(
+ `switch ${i + 1} clicked card ${targetId} but it never became active — the measurement is not valid`
+ )
+ }
+ samples.push(sample)
+ console.error(
+ `switch ${i + 1}/${args.switches} -> firstPaint=${sample.firstPaintMs}ms ` +
+ `maxFrameGap=${sample.maxFrameGapMs}ms attrFlip=${sample.attrFlipMs}ms ` +
+ `rendered=${sample.renderedCommitMs}ms longTasks=${sample.longTaskCount}/${sample.longTaskTotalMs}ms`
+ )
+ }
+
+ // Why: the first switch after attach pays one-off lazy work (chunk loads,
+ // cold caches) that a warm app never pays again. Keep it in the raw samples
+ // but exclude it from the verdict so bisect steps compare like with like.
+ const scored = samples.length > 1 ? samples.slice(1) : samples
+
+ const report = {
+ port: args.port,
+ switches: samples.length,
+ budgetMs: args.budgetMs,
+ firstPaintMs: summarize(scored.map((s) => s.firstPaintMs)),
+ maxFrameGapMs: summarize(scored.map((s) => s.maxFrameGapMs)),
+ attrFlipMs: summarize(scored.map((s) => s.attrFlipMs)),
+ renderedCommitMs: summarize(scored.map((s) => s.renderedCommitMs)),
+ worstLongTaskMs: summarize(scored.map((s) => s.worstLongTaskMs)),
+ longTaskTotalMs: summarize(scored.map((s) => s.longTaskTotalMs)),
+ samples
+ }
+
+ const medianFirstPaint = report.firstPaintMs?.p50 ?? Infinity
+ const medianFrameGap = report.maxFrameGapMs?.p50 ?? Infinity
+ // The visible symptom is a stall, which shows up either as a late first frame
+ // or as a long gap between frames right after the click.
+ const verdictMs = Math.max(medianFirstPaint, medianFrameGap)
+ report.verdictMs = +verdictMs.toFixed(1)
+ report.pass = verdictMs <= args.budgetMs
+
+ if (args.out) {
+ mkdirSync(path.dirname(args.out), { recursive: true })
+ writeFileSync(args.out, JSON.stringify(report, null, 2))
+ }
+ if (args.json) {
+ console.log(JSON.stringify(report, null, 2))
+ } else {
+ console.log('')
+ console.log(
+ `first paint after click p50=${report.firstPaintMs?.p50}ms max=${report.firstPaintMs?.max}ms`
+ )
+ console.log(
+ `max frame gap p50=${report.maxFrameGapMs?.p50}ms max=${report.maxFrameGapMs?.max}ms`
+ )
+ console.log(`attribute flip (JS) p50=${report.attrFlipMs?.p50}ms`)
+ console.log(
+ `main pane rendered p50=${report.renderedCommitMs?.p50}ms max=${report.renderedCommitMs?.max}ms`
+ )
+ console.log(
+ `worst long task p50=${report.worstLongTaskMs?.p50}ms max=${report.worstLongTaskMs?.max}ms`
+ )
+ console.log('')
+ console.log(
+ `${report.pass ? 'PASS' : 'FAIL'} verdict=${report.verdictMs}ms budget=${args.budgetMs}ms`
+ )
+ }
+
+ await browser.close()
+ process.exit(report.pass ? 0 : 1)
+}
+
+main().catch((error) => {
+ console.error(error?.stack ?? String(error))
+ // 125 tells `git bisect run` to skip rather than mark the commit bad.
+ process.exit(125)
+})
From d07931c4c2f150a01781a3d61b8814258fbbb432 Mon Sep 17 00:00:00 2001
From: OrcaWin
Date: Tue, 28 Jul 2026 23:50:53 -0700
Subject: [PATCH 17/67] fix(mobile): keep host action drawer close stable
(#11306)
---
config/reliability-gates.jsonc | 97 +++++++++++++++++
mobile/src/components/BottomDrawer.tsx | 38 ++++++-
.../bottom-drawer-close-lifecycle.test.ts | 103 ++++++++++++++++++
3 files changed, 233 insertions(+), 5 deletions(-)
create mode 100644 mobile/src/components/bottom-drawer-close-lifecycle.test.ts
diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc
index f5aa19d93e7..a6f4f2d22f5 100644
--- a/config/reliability-gates.jsonc
+++ b/config/reliability-gates.jsonc
@@ -16,6 +16,103 @@
}
},
"gates": [
+ {
+ "id": "mobile-ui.drawer-close-continuity",
+ "title": "Mobile drawers finish closing despite parent rerenders",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "mobile-ui",
+ "layer": "react-native-modal-lifecycle",
+ "surfaces": [
+ "host action sheet",
+ "host rename navigation",
+ "host removal confirmation",
+ "shared mobile bottom drawers"
+ ],
+ "platforms": [
+ "ios",
+ "android",
+ "macos"
+ ],
+ "providers": [
+ "provider-independent"
+ ],
+ "coveredPlatforms": [
+ "ios",
+ "macos"
+ ],
+ "coveredProviders": [
+ "provider-independent"
+ ],
+ "coverageNotes": "A deterministic React lifecycle test proves callback churn cannot restart an in-flight drawer close, and an iOS 26.5 simulator run covers Edit Host plus confirmed host removal. Android native-modal behavior remains a live-test gap.",
+ "motivatingLinks": [
+ "https://github.com/stablyai/orca/issues/8791"
+ ],
+ "invariant": "Once a bottom drawer begins closing, unrelated parent rerenders must not replace its completion callback or restart the native hide animation. The drawer must unmount once and deliver the latest after-close action exactly once.",
+ "oracle": "Render one drawer, begin closing it, rerender with new parent callbacks before completion, and require every MountedBottomDrawer frame to retain one onHidden identity. Trigger that completion barrier repeatedly, then require the drawer's null render to commit before only the latest after-close callback runs exactly once.",
+ "commands": [
+ "pnpm --dir mobile exec vitest run --root .. mobile/src/components/bottom-drawer-close-lifecycle.test.ts",
+ "Manual iOS 26.5 simulator: long-press paired host; open Edit host; return; long-press host; Remove; confirm Remove; assert host disappears"
+ ],
+ "testFiles": [
+ "mobile/src/components/bottom-drawer-close-lifecycle.test.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "mobile/src/components/bottom-drawer-close-lifecycle.test.ts",
+ "assertions": [
+ "keeps close stable and delivers the latest action once after unmount"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-07-28",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/components/bottom-drawer-close-lifecycle.test.ts",
+ "result": "passed",
+ "durationSeconds": 0.18,
+ "summary": "The focused lifecycle harness passed with stable completion identity, latest-callback delivery, and drawer unmount assertions."
+ },
+ {
+ "date": "2026-07-28",
+ "runner": "manual",
+ "platform": "ios",
+ "command": "Manual iOS 26.5 simulator: long-press paired host; open Edit host; return; long-press host; Remove; confirm Remove; assert host disappears",
+ "result": "passed",
+ "durationSeconds": 37,
+ "summary": "Edit host opened responsively after the drawer closed; returning and confirming Remove deleted the host without freezing."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 5,
+ "scope": "focused React lifecycle contract test"
+ },
+ "flakeHistory": {
+ "status": "unknown",
+ "evidence": "One deterministic local contract run and one iOS simulator flow exist; CI and soak history are not yet available."
+ },
+ "redGreenEvidence": {
+ "status": "complete",
+ "evidence": "On current main, the harness failed because each parent render created a different onHidden callback. The stable completion callback fix passes the byte-identical oracle; restoring the inline callback reproduces the failure."
+ },
+ "performanceBudget": {
+ "required": true,
+ "evidence": "Any number of parent rerenders retains one close-completion identity, so they add zero hide-animation restarts, timers, listeners, or after-close deliveries."
+ },
+ "promotionCriteria": [
+ "Collect 100 consecutive CI passes or 14 days of soak history.",
+ "Run the host Edit and Remove flows on a physical iOS device and an Android emulator or device.",
+ "Keep the callback-identity and exactly-once delivery assertions intact for every shared drawer lifecycle change."
+ ],
+ "knownGaps": [
+ "Android native-modal behavior has no live evidence.",
+ "The simulator run used an unreachable stored host rather than a connected multi-worktree host.",
+ "The contract test injects the hide-completion barrier instead of running Reanimated."
+ ],
+ "demotionRule": "Keep experimental or demote if parent rerenders can restart drawer hiding, after-close delivery duplicates or goes stale, the focused contract flakes, or either mobile platform retains a touch-blocking modal."
+ },
{
"id": "mobile-relay.endpoint-recovery",
"title": "Mobile relay recovery retries offline hosts and races direct endpoints",
diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx
index 030e38bcd3f..9d27c2c0c4e 100644
--- a/mobile/src/components/BottomDrawer.tsx
+++ b/mobile/src/components/BottomDrawer.tsx
@@ -1,4 +1,4 @@
-import { type ReactNode, useState } from 'react'
+import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { resolveBottomDrawerMounted } from './bottom-drawer-mount-state'
import { MountedBottomDrawer } from './mounted-bottom-drawer'
@@ -31,6 +31,37 @@ export function BottomDrawer({
zIndex
}: Props) {
const [mounted, setMounted] = useState(visible)
+ const onAfterCloseRef = useRef(onAfterClose)
+ const hiddenHandledRef = useRef(false)
+ const afterClosePendingRef = useRef(false)
+
+ useEffect(() => {
+ onAfterCloseRef.current = onAfterClose
+ }, [onAfterClose])
+
+ useEffect(() => {
+ if (visible) {
+ hiddenHandledRef.current = false
+ afterClosePendingRef.current = false
+ }
+ }, [visible])
+
+ useEffect(() => {
+ if (mounted || !afterClosePendingRef.current) {
+ return
+ }
+ afterClosePendingRef.current = false
+ onAfterCloseRef.current?.()
+ }, [mounted])
+
+ const handleHidden = useCallback(() => {
+ if (hiddenHandledRef.current) {
+ return
+ }
+ hiddenHandledRef.current = true
+ afterClosePendingRef.current = true
+ setMounted(false)
+ }, [])
const resolvedMounted = resolveBottomDrawerMounted(visible, mounted)
// Why: opening drawers should mount before commit; waiting for a passive
@@ -49,10 +80,7 @@ export function BottomDrawer({
{
- setMounted(false)
- onAfterClose?.()
- }}
+ onHidden={handleHidden}
dragContentToDismiss={dragContentToDismiss}
contentScrollable={contentScrollable}
fillAvailable={fillAvailable}
diff --git a/mobile/src/components/bottom-drawer-close-lifecycle.test.ts b/mobile/src/components/bottom-drawer-close-lifecycle.test.ts
new file mode 100644
index 00000000000..b4362412db7
--- /dev/null
+++ b/mobile/src/components/bottom-drawer-close-lifecycle.test.ts
@@ -0,0 +1,103 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { BottomDrawer } from './BottomDrawer'
+
+vi.mock('./mounted-bottom-drawer', () => ({
+ MountedBottomDrawer: 'MountedBottomDrawer'
+}))
+
+function renderDrawer(
+ visible: boolean,
+ onClose: () => void,
+ onAfterClose: () => void
+): ReactTestRenderer {
+ let renderer: ReactTestRenderer | null = null
+ act(() => {
+ renderer = create(
+ createElement(
+ BottomDrawer,
+ { visible, onClose, onAfterClose },
+ createElement('DrawerContent')
+ )
+ )
+ })
+ if (!renderer) {
+ throw new Error('Bottom drawer did not render')
+ }
+ return renderer
+}
+
+function updateDrawer(
+ renderer: ReactTestRenderer,
+ visible: boolean,
+ onClose: () => void,
+ onAfterClose: () => void
+): void {
+ act(() => {
+ renderer.update(
+ createElement(
+ BottomDrawer,
+ { visible, onClose, onAfterClose },
+ createElement('DrawerContent')
+ )
+ )
+ })
+}
+
+function mountedDrawer(renderer: ReactTestRenderer) {
+ return renderer.root.findByType('MountedBottomDrawer')
+}
+
+describe('BottomDrawer close lifecycle', () => {
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ const originalConsoleError = console.error
+ vi.spyOn(console, 'error').mockImplementation((...args) => {
+ const message = args[0]
+ if (
+ typeof message === 'string' &&
+ (message.includes('react-test-renderer is deprecated') ||
+ message.includes('The current testing environment is not configured to support act'))
+ ) {
+ return
+ }
+ originalConsoleError(...args)
+ })
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('keeps close stable and delivers the latest action once after unmount', () => {
+ const firstAfterClose = vi.fn()
+ const rendered: { current?: ReactTestRenderer } = {}
+ const latestAfterClose = vi.fn(() => {
+ expect(rendered.current?.toJSON()).toBeNull()
+ })
+ const renderer = renderDrawer(true, vi.fn(), firstAfterClose)
+ rendered.current = renderer
+ const initialOnHidden = mountedDrawer(renderer).props.onHidden
+
+ updateDrawer(renderer, false, vi.fn(), firstAfterClose)
+ const closingOnHidden = mountedDrawer(renderer).props.onHidden
+ updateDrawer(renderer, false, vi.fn(), latestAfterClose)
+ const rerenderedOnHidden = mountedDrawer(renderer).props.onHidden
+
+ expect(closingOnHidden).toBe(initialOnHidden)
+ expect(rerenderedOnHidden).toBe(initialOnHidden)
+
+ act(() => {
+ rerenderedOnHidden()
+ rerenderedOnHidden()
+ })
+ act(() => {
+ rerenderedOnHidden()
+ })
+
+ expect(firstAfterClose).not.toHaveBeenCalled()
+ expect(latestAfterClose).toHaveBeenCalledTimes(1)
+ expect(renderer.toJSON()).toBeNull()
+ })
+})
From 6cc579a48cc71eb663fddd02ecbf348564efc1f7 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Wed, 29 Jul 2026 00:11:04 -0700
Subject: [PATCH 18/67] perf(vault): dedupe scope paths by key instead of
rescanning (#11314)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* perf(vault): dedupe scope paths by key instead of rescanning
#11303 took the session maps off the workspace-switch path, but scope path
derivation still follows the active worktree and stayed quadratic.
addAiVaultWorkspaceScopePath deduped by re-normalizing every already
accepted path on each insert, so accepting K paths cost O(K^2)
normalize('NFC') calls — ~632k at 1124 workspaces. isAiVaultWorkspaceScopePathClaimed
separately rescanned every live worktree per prior id, and runs twice per
switch via activeWorktreePaths and scopePaths.
- carry a Set of comparison keys alongside the paths, so each insert is one
normalize plus one Set lookup
- thread that accumulator from the workspace pass into the project pass
rather than restarting deduplication against a plain array
- build one comparison-path -> worktree id map for the claim check, keeping
first-writer-wins to match the previous some() short-circuit
deriveAiVaultScopeSessionPaths on a real 1124-workspace profile:
189.7ms -> 0.8ms. Output is unchanged, including ordering: verified against
the previous implementation across 117 scenario/option combinations
covering monorepo and mixed-repo layouts, priors both claimed and
unclaimed, duplicate paths, NFD/NFC, WSL UNC, trailing and doubled
separators, relative and blank paths, and four project-key shapes.
Adds the first test file for this module: scope semantics (priors, claimed
priors, cross-repo rejection, dedupe, NFD/NFC) plus a timing guard.
Verified fail-first — the guard reports 220ms on the previous
implementation. Path length is chosen deliberately, since normalize() cost
scales with it and short synthetic paths understate the old shape.
* fix(vault): make the claim check independent of worktree ordering
Review catch on the first pass: keying claims by comparison path meant a
duplicate path had to pick one owner, and picking the active worktree
masked a real claimant later in the list. Concretely, with the active
worktree also listed at its own prior path, the prior was reported
unclaimed where the previous some() reported it claimed.
Excludes the active worktree while building the set instead of comparing
ids at read time, so any surviving entry is a claim by construction and
ordering cannot decide the result.
Adds a test over four orderings, verified fail-first against the previous
commit. Also adds a timing guard for deriveAiVaultWorkspaceScopePaths,
which the session-scope guard did not cover.
Equivalence rerun against the pre-optimization implementation: 156
scenario/option combinations, identical paths and ordering.
---
.../ai-vault-scope-paths.test.ts | 192 ++++++++++++++++++
.../right-sidebar/ai-vault-scope-paths.ts | 101 ++++++---
2 files changed, 268 insertions(+), 25 deletions(-)
create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-scope-paths.test.ts
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.test.ts
new file mode 100644
index 00000000000..bf11a0af23b
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.test.ts
@@ -0,0 +1,192 @@
+import { describe, expect, it } from 'vitest'
+import type { Worktree } from '../../../../shared/types'
+import {
+ deriveAiVaultScopeSessionPaths,
+ deriveAiVaultWorkspaceScopePaths
+} from './ai-vault-scope-paths'
+
+function makeWorktree(overrides: Partial = {}): Worktree {
+ return {
+ id: 'repo-1::/repo/orca',
+ repoId: 'repo-1',
+ displayName: 'orca',
+ path: '/repo/orca',
+ head: 'abc123',
+ branch: 'main',
+ isBare: false,
+ comment: '',
+ linkedIssue: null,
+ linkedPR: null,
+ linkedLinearIssue: null,
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 0,
+ lastActivityAt: 1,
+ isMainWorktree: false,
+ ...overrides
+ }
+}
+
+describe('deriveAiVaultWorkspaceScopePaths', () => {
+ it('returns the active workspace path', () => {
+ const active = makeWorktree()
+ expect(deriveAiVaultWorkspaceScopePaths(active, [active])).toEqual(['/repo/orca'])
+ })
+
+ it('returns nothing without an active workspace', () => {
+ expect(deriveAiVaultWorkspaceScopePaths(null, [makeWorktree()])).toEqual([])
+ })
+
+ it('includes prior paths so renamed workspaces keep their transcripts', () => {
+ const active = makeWorktree({
+ id: 'repo-1::/repo/orca-renamed',
+ path: '/repo/orca-renamed',
+ priorWorktreeIds: ['repo-1::/repo/orca']
+ })
+
+ expect(deriveAiVaultWorkspaceScopePaths(active, [active])).toEqual([
+ '/repo/orca-renamed',
+ '/repo/orca'
+ ])
+ })
+
+ it('drops a prior path another live workspace now owns', () => {
+ // Sessions are keyed by cwd alone, so claiming a path a live workspace
+ // occupies would show that workspace's transcripts under this one.
+ const claimant = makeWorktree({ id: 'repo-1::/repo/orca', path: '/repo/orca' })
+ const active = makeWorktree({
+ id: 'repo-1::/repo/orca-renamed',
+ path: '/repo/orca-renamed',
+ priorWorktreeIds: ['repo-1::/repo/orca']
+ })
+
+ expect(deriveAiVaultWorkspaceScopePaths(active, [claimant, active])).toEqual([
+ '/repo/orca-renamed'
+ ])
+ })
+
+ it('keeps a prior path the active workspace itself still owns', () => {
+ const active = makeWorktree({ priorWorktreeIds: ['repo-1::/repo/orca'] })
+ expect(deriveAiVaultWorkspaceScopePaths(active, [active])).toEqual(['/repo/orca'])
+ })
+
+ it('drops a claimed prior path regardless of where the claimant sits in the list', () => {
+ // Ordering must not decide the claim: a lookup keyed by path has to
+ // exclude the active workspace up front, or listing it before a claimant
+ // that shares the path would mask the claim.
+ const active = makeWorktree({
+ id: 'repo-1::/repo/orca-renamed',
+ path: '/repo/orca-renamed',
+ priorWorktreeIds: ['repo-1::/repo/orca']
+ })
+ const claimant = makeWorktree({ id: 'repo-1::/repo/orca', path: '/repo/orca' })
+ // The active workspace also listed at the prior path — the only shape where
+ // a first-writer-wins map would name the active workspace the owner and so
+ // report the path unclaimed.
+ const activeAtPriorPath = makeWorktree({ id: active.id, path: '/repo/orca' })
+
+ for (const liveWorktrees of [
+ [active, claimant],
+ [claimant, active],
+ [activeAtPriorPath, claimant],
+ [claimant, activeAtPriorPath]
+ ]) {
+ expect(deriveAiVaultWorkspaceScopePaths(active, liveWorktrees)).toEqual([
+ '/repo/orca-renamed'
+ ])
+ }
+ })
+
+ it('derives workspace scope paths at scale', () => {
+ // Separate from the session-scope guard: a quadratic dedupe reintroduced
+ // only in the workspace pass would not surface there.
+ const prefix = '/Users/dev/orca/workspaces/orca-monorepo/feature-'
+ const worktrees = Array.from({ length: 1200 }, (_, i) =>
+ makeWorktree({ id: `repo-1::${prefix}${i}`, path: `${prefix}${i}` })
+ )
+ const active = makeWorktree({
+ id: `repo-1::${prefix}0`,
+ path: `${prefix}0`,
+ // Priors drive the claim check, which is the other per-call scan here.
+ priorWorktreeIds: Array.from({ length: 25 }, (_, i) => `repo-1::${prefix}prior-${i}`)
+ })
+
+ const startedAt = performance.now()
+ const paths = deriveAiVaultWorkspaceScopePaths(active, worktrees)
+ const elapsedMs = performance.now() - startedAt
+
+ expect(paths).toHaveLength(1 + 25)
+ expect(elapsedMs).toBeLessThan(100)
+ })
+
+ it('ignores prior ids belonging to another repo', () => {
+ const active = makeWorktree({ priorWorktreeIds: ['repo-2::/repo/other'] })
+ expect(deriveAiVaultWorkspaceScopePaths(active, [active])).toEqual(['/repo/orca'])
+ })
+
+ it('skips relative and blank paths', () => {
+ const active = makeWorktree({ path: 'relative/path' })
+ expect(deriveAiVaultWorkspaceScopePaths(active, [active])).toEqual([])
+ expect(deriveAiVaultWorkspaceScopePaths(makeWorktree({ path: ' ' }), [])).toEqual([])
+ })
+})
+
+describe('deriveAiVaultScopeSessionPaths', () => {
+ it('covers the active workspace plus the rest of its repo', () => {
+ const active = makeWorktree()
+ const sibling = makeWorktree({ id: 'repo-1::/repo/feature', path: '/repo/feature' })
+ const otherRepo = makeWorktree({
+ id: 'repo-2::/repo/other',
+ repoId: 'repo-2',
+ path: '/repo/other'
+ })
+
+ expect(deriveAiVaultScopeSessionPaths(active, [active, sibling, otherRepo])).toEqual([
+ '/repo/orca',
+ '/repo/feature'
+ ])
+ })
+
+ it('deduplicates paths that differ only by separators or trailing slash', () => {
+ // The dedupe compares normalized keys; the first spelling is what ships.
+ const active = makeWorktree()
+ const trailing = makeWorktree({ id: 'repo-1::a', path: '/repo/orca/' })
+ const doubled = makeWorktree({ id: 'repo-1::b', path: '/repo//orca' })
+
+ expect(deriveAiVaultScopeSessionPaths(active, [active, trailing, doubled])).toEqual([
+ '/repo/orca'
+ ])
+ })
+
+ it('treats NFD and NFC spellings of one path as the same scope entry', () => {
+ // macOS yields NFD on disk while agents record NFC cwds (#10832).
+ const nfc = '/repo/프로젝트'
+ const active = makeWorktree({ id: `repo-1::${nfc}`, path: nfc })
+ const nfd = makeWorktree({ id: 'repo-1::nfd', path: nfc.normalize('NFD') })
+
+ expect(deriveAiVaultScopeSessionPaths(active, [active, nfd])).toEqual([nfc])
+ })
+
+ it('derives scope paths at scale without re-normalizing the accumulator', () => {
+ // Regression guard: deduping by rescanning the accumulated paths made this
+ // O(n^2) in normalize('NFC') and cost ~190ms on a 1124-workspace profile —
+ // on the workspace-switch path, since these paths follow the active
+ // worktree. Times the real fan-out so it fails on a return to that shape.
+ // Path length matters as much as count: normalize() cost scales with it,
+ // so short synthetic paths would understate the old shape. ~50 chars
+ // matches the real profile this was measured on.
+ const prefix = '/Users/dev/orca/workspaces/orca-monorepo/feature-'
+ const worktrees = Array.from({ length: 1200 }, (_, i) =>
+ makeWorktree({ id: `repo-1::${prefix}${i}`, path: `${prefix}${i}` })
+ )
+
+ const startedAt = performance.now()
+ const paths = deriveAiVaultScopeSessionPaths(worktrees[0], worktrees)
+ const elapsedMs = performance.now() - startedAt
+
+ expect(paths).toHaveLength(worktrees.length)
+ // ~190ms before, ~1ms after; loose enough for a slow CI box.
+ expect(elapsedMs).toBeLessThan(100)
+ })
+})
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts b/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts
index 6aad12c03bd..7bc85b13093 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts
@@ -14,21 +14,33 @@ export function deriveAiVaultWorkspaceScopePaths(
return []
}
- const paths: string[] = []
- addAiVaultWorkspaceScopePath(paths, activeWorktree.path)
+ return collectWorkspaceScopePaths(activeWorktree, liveWorktrees).paths
+}
- for (const priorWorktreeId of activeWorktree.priorWorktreeIds ?? []) {
+function collectWorkspaceScopePaths(
+ activeWorktree: Pick,
+ liveWorktrees: readonly Pick[]
+): ScopePathAccumulator {
+ const accumulator = createScopePathAccumulator()
+ addAiVaultWorkspaceScopePath(accumulator, activeWorktree.path)
+
+ const priorWorktreeIds = activeWorktree.priorWorktreeIds ?? []
+ // Built once instead of rescanning every live worktree per prior id.
+ const claimedComparisonPaths =
+ priorWorktreeIds.length > 0 ? buildClaimedComparisonPaths(liveWorktrees, activeWorktree) : null
+
+ for (const priorWorktreeId of priorWorktreeIds) {
const parsed = splitWorktreeIdForFilesystem(priorWorktreeId)
if (!parsed || parsed.repoId !== activeWorktree.repoId) {
continue
}
- if (isAiVaultWorkspaceScopePathClaimed(parsed.worktreePath, activeWorktree, liveWorktrees)) {
+ if (isAiVaultWorkspaceScopePathClaimed(parsed.worktreePath, claimedComparisonPaths)) {
continue
}
- addAiVaultWorkspaceScopePath(paths, parsed.worktreePath)
+ addAiVaultWorkspaceScopePath(accumulator, parsed.worktreePath)
}
- return paths
+ return accumulator
}
/**
@@ -48,10 +60,12 @@ export function deriveAiVaultScopeSessionPaths(
projectHostSetupProjection?: ProjectHostSetupProjection
} = {}
): string[] {
- const paths = deriveAiVaultWorkspaceScopePaths(activeWorktree, liveWorktrees)
if (!activeWorktree) {
- return paths
+ return []
}
+ // Carries the workspace pass's dedupe keys forward, so the project pass does
+ // not restart deduplication against a plain array.
+ const accumulator = collectWorkspaceScopePaths(activeWorktree, liveWorktrees)
const setupsByRepoId = buildProjectSetupsByRepoId(options.projectHostSetupProjection)
for (const worktree of liveWorktrees) {
if (
@@ -61,15 +75,15 @@ export function deriveAiVaultScopeSessionPaths(
(setup) => worktreeProjectKey(setup, setup) === options.activeProjectKey
)
) {
- addAiVaultWorkspaceScopePath(paths, worktree.path)
+ addAiVaultWorkspaceScopePath(accumulator, worktree.path)
}
}
for (const setup of options.projectHostSetupProjection?.setups ?? []) {
if (worktreeProjectKey(setup, setup) === options.activeProjectKey) {
- addAiVaultWorkspaceScopePath(paths, setup.path)
+ addAiVaultWorkspaceScopePath(accumulator, setup.path)
}
}
- return paths
+ return accumulator.paths
}
function buildProjectSetupsByRepoId(
@@ -95,34 +109,71 @@ function worktreeProjectKey(
return entry.repoId ? `repo:${entry.repoId}` : null
}
-function addAiVaultWorkspaceScopePath(paths: string[], pathValue: string): void {
+/**
+ * Paths plus their comparison keys.
+ *
+ * Why the key set: deduping by rescanning the accumulated paths re-normalized
+ * every accepted path on every insert, which is O(n^2) `normalize('NFC')` calls
+ * and cost ~190ms on a 1124-workspace profile — on the workspace-switch path,
+ * since these paths are derived from the active worktree.
+ */
+type ScopePathAccumulator = {
+ paths: string[]
+ comparisonKeys: Set
+}
+
+function createScopePathAccumulator(): ScopePathAccumulator {
+ return { paths: [], comparisonKeys: new Set() }
+}
+
+function addAiVaultWorkspaceScopePath(accumulator: ScopePathAccumulator, pathValue: string): void {
const trimmedPath = pathValue.trim()
if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
return
}
const comparisonPath = normalizeRuntimePathForComparison(trimmedPath)
- if (
- paths.some((existingPath) => normalizeRuntimePathForComparison(existingPath) === comparisonPath)
- ) {
+ if (accumulator.comparisonKeys.has(comparisonPath)) {
return
}
- paths.push(trimmedPath)
+ accumulator.comparisonKeys.add(comparisonPath)
+ accumulator.paths.push(trimmedPath)
+}
+
+/**
+ * Comparison paths owned by a worktree *other than* the active one.
+ *
+ * Why exclude the active worktree while building rather than when reading: a
+ * path→id map would otherwise have to pick one owner among duplicates, and
+ * picking the active worktree would mask a real claimant sitting later in the
+ * list. Excluding it up front means any surviving entry is a claim by
+ * definition, which matches the previous `some()` regardless of ordering.
+ */
+function buildClaimedComparisonPaths(
+ liveWorktrees: readonly Pick[],
+ activeWorktree: Pick
+): Set {
+ const claimedPaths = new Set()
+ for (const worktree of liveWorktrees) {
+ if (worktree.id === activeWorktree.id) {
+ continue
+ }
+ const trimmedPath = worktree.path.trim()
+ if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
+ continue
+ }
+ claimedPaths.add(normalizeRuntimePathForComparison(trimmedPath))
+ }
+ return claimedPaths
}
function isAiVaultWorkspaceScopePathClaimed(
pathValue: string,
- activeWorktree: Pick,
- liveWorktrees: readonly Pick[]
+ claimedComparisonPaths: Set | null
): boolean {
const trimmedPath = pathValue.trim()
- if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
+ if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath) || !claimedComparisonPaths) {
return false
}
- const comparisonPath = normalizeRuntimePathForComparison(trimmedPath)
// AI Vault sessions are keyed by cwd only, so any live worktree now owning this path wins.
- return liveWorktrees.some(
- (worktree) =>
- worktree.id !== activeWorktree.id &&
- normalizeRuntimePathForComparison(worktree.path) === comparisonPath
- )
+ return claimedComparisonPaths.has(normalizeRuntimePathForComparison(trimmedPath))
}
From b4b3bcdb8463f2936aeab532e46f48e0b5fdfca4 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Wed, 29 Jul 2026 00:20:47 -0700
Subject: [PATCH 19/67] perf(vault): look up resume worktrees through the
shared index (#11317)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Resolving a session's resume target scanned every worktree in every repo:
two `Object.values(worktreesByRepo).flat().find(...)` calls, which also
allocate a fresh 1124-element array each time, plus an equivalent `some()`
walk. All three run per visible session row, so a panel render repeated
them ~20 times.
`getIndexedWorktreeMap` already exists for this and is WeakMap-cached on
`worktreesByRepo`, so the index is built once per store snapshot rather
than per call. `connection-owner-resolution.ts` already resolves worktrees
this way.
~1.6ms -> ~0.002ms per render pass at 1124 worktrees. Net -4 lines.
Behavior is unchanged: the map dedupes by id, which only diverges from
`find()` when one id appears twice with different objects. Worktree ids are
`repoId::path`, so a duplicate id within a repo array — the documented race
the index was built for — refers to the same worktree.
---
.../components/right-sidebar/ai-vault-session-resume.ts | 5 ++---
src/renderer/src/lib/ai-vault-resume-target.ts | 9 +++------
2 files changed, 5 insertions(+), 9 deletions(-)
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts
index a166adf011c..9465144b705 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts
@@ -9,6 +9,7 @@ import {
type AiVaultSession
} from '../../../../shared/ai-vault-types'
import type { AppState } from '@/store/types'
+import { getIndexedWorktreeMap } from '@/store/worktree-repo-index'
import { translate } from '@/i18n/i18n'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import {
@@ -145,9 +146,7 @@ export function isKnownAiVaultResumeWorkspaceTarget(
}
const worktreeId = workspaceKey?.type === 'worktree' ? workspaceKey.worktreeId : workspaceId
- return Object.values(state.worktreesByRepo).some((worktrees) =>
- worktrees.some((worktree) => worktree.id === worktreeId)
- )
+ return getIndexedWorktreeMap(state.worktreesByRepo).has(worktreeId)
}
function resolveSupportedResumeWorktreeId(args: {
diff --git a/src/renderer/src/lib/ai-vault-resume-target.ts b/src/renderer/src/lib/ai-vault-resume-target.ts
index f52e26c62e8..3c9206ae372 100644
--- a/src/renderer/src/lib/ai-vault-resume-target.ts
+++ b/src/renderer/src/lib/ai-vault-resume-target.ts
@@ -11,6 +11,7 @@ import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import { isWslUncPath } from '../../../shared/wsl-paths'
import type { AppState } from '@/store/types'
+import { getIndexedWorktreeMap } from '@/store/worktree-repo-index'
import { getFolderWorkspaceCandidateRepos } from './folder-workspace-connection'
export type AiVaultResumeTargetStatus = 'local' | 'ssh' | 'runtime' | 'unknown'
@@ -132,9 +133,7 @@ export function getAiVaultResumeWorkspaceExecutionHostId(
}
const worktreeId = workspaceKey?.type === 'worktree' ? workspaceKey.worktreeId : workspaceId
- const worktree = Object.values(state.worktreesByRepo ?? {})
- .flat()
- .find((candidate) => candidate.id === worktreeId)
+ const worktree = getIndexedWorktreeMap(state.worktreesByRepo ?? {}).get(worktreeId)
const worktreeHostId = normalizeExecutionHostId(worktree?.hostId)
if (worktreeHostId) {
return worktreeHostId
@@ -158,9 +157,7 @@ export function getAiVaultResumeWorkspaceTargetStatus(
}
const worktreeId = workspaceKey?.type === 'worktree' ? workspaceKey.worktreeId : workspaceId
- const worktree = Object.values(state.worktreesByRepo ?? {})
- .flat()
- .find((candidate) => candidate.id === worktreeId)
+ const worktree = getIndexedWorktreeMap(state.worktreesByRepo ?? {}).get(worktreeId)
const worktreeHost = getAiVaultResumeExecutionHostTargetStatus(worktree?.hostId)
if (worktreeHost !== 'unknown') {
return worktreeHost
From e549dd3ef4c0a5929c3e976a09d369195a2e159a Mon Sep 17 00:00:00 2001
From: OrcaWin
Date: Wed, 29 Jul 2026 00:35:06 -0700
Subject: [PATCH 20/67] fix(agents): make pane retention transfer-aware instead
of suppressor-based (#11310)
---
.../dashboard/useRetainedAgents.test.ts | 95 +++-
.../components/dashboard/useRetainedAgents.ts | 114 ++++-
.../dashboard/useRetainedAgentsSync.test.ts | 61 ++-
.../store/slices/agent-pane-authority.test.ts | 49 +-
.../src/store/slices/agent-pane-authority.ts | 59 ++-
.../agent-status-worktree-purge-leak.test.ts | 30 ++
src/renderer/src/store/slices/agent-status.ts | 110 ++---
...erminal-pane-detach-agent-identity.test.ts | 9 +-
...rminal-pane-detach-agent-retention.test.ts | 431 +++++++++++++++---
src/renderer/src/store/slices/worktrees.ts | 4 +
10 files changed, 793 insertions(+), 169 deletions(-)
diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.test.ts b/src/renderer/src/components/dashboard/useRetainedAgents.test.ts
index e0f668d3b6d..095bdedcb67 100644
--- a/src/renderer/src/components/dashboard/useRetainedAgents.test.ts
+++ b/src/renderer/src/components/dashboard/useRetainedAgents.test.ts
@@ -2,19 +2,22 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { useAppStore } from '@/store'
+import { resetAgentPaneAuthorityAliasesForTests } from '@/store/slices/agent-pane-authority'
import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types'
import { makePaneKey } from '../../../../shared/stable-pane-id'
-import type { Repo, Worktree } from '../../../../shared/types'
+import type { Repo, TerminalTab, Worktree } from '../../../../shared/types'
import { collectRetainedAgentsOnDisappear, useRetainedAgentsSync } from './useRetainedAgents'
const initialAppState = useAppStore.getInitialState()
beforeEach(() => {
useAppStore.setState(initialAppState, true)
+ resetAgentPaneAuthorityAliasesForTests()
})
afterEach(() => {
useAppStore.setState(initialAppState, true)
+ resetAgentPaneAuthorityAliasesForTests()
})
function makeRepo(): Repo {
@@ -49,6 +52,19 @@ function makeWorktree(): Worktree {
}
}
+function makeTab(overrides: Partial & { id: string }): TerminalTab {
+ return {
+ worktreeId: 'wt-1',
+ title: 'Terminal',
+ ptyId: null,
+ customTitle: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 1,
+ ...overrides
+ }
+}
+
function makeAgentRow(args: { paneKey: string; state: AgentStatusState; interrupted?: boolean }) {
const entry: AgentStatusEntry = {
state: args.state,
@@ -65,16 +81,7 @@ function makeAgentRow(args: { paneKey: string; state: AgentStatusState; interrup
return {
paneKey: args.paneKey,
entry,
- tab: {
- id: 'tab-1',
- worktreeId: 'wt-1',
- title: 'Terminal',
- ptyId: null,
- customTitle: null,
- color: null,
- sortOrder: 0,
- createdAt: 1
- },
+ tab: makeTab({ id: 'tab-1' }),
agentType: 'claude' as const,
state: args.state,
startedAt: 1
@@ -92,7 +99,8 @@ describe('collectRetainedAgentsOnDisappear', () => {
currentAgents: new Map(),
retainedAgentsByPaneKey: {},
retentionSuppressedPaneKeys: {},
- recentlyClosedAgentStatusTabIds: { 'tab-2': true }
+ recentlyClosedAgentStatusTabIds: { 'tab-2': true },
+ recentlyRetiredAgentStatusPaneKeys: {}
})
expect(result.toRetain).toHaveLength(1)
@@ -116,7 +124,8 @@ describe('collectRetainedAgentsOnDisappear', () => {
currentAgents: new Map(),
retainedAgentsByPaneKey: {},
retentionSuppressedPaneKeys: {},
- recentlyClosedAgentStatusTabIds: {}
+ recentlyClosedAgentStatusTabIds: {},
+ recentlyRetiredAgentStatusPaneKeys: {}
})
expect(result.toRetain).toEqual([])
@@ -145,7 +154,8 @@ describe('collectRetainedAgentsOnDisappear', () => {
currentAgents: new Map(),
retainedAgentsByPaneKey: { 'tab-1:1': staleRetained },
retentionSuppressedPaneKeys: {},
- recentlyClosedAgentStatusTabIds: {}
+ recentlyClosedAgentStatusTabIds: {},
+ recentlyRetiredAgentStatusPaneKeys: {}
})
expect(result.toRetain).toHaveLength(1)
@@ -170,7 +180,8 @@ describe('collectRetainedAgentsOnDisappear', () => {
currentAgents: new Map(),
retainedAgentsByPaneKey: { 'tab-1:1': sameRunRetained },
retentionSuppressedPaneKeys: {},
- recentlyClosedAgentStatusTabIds: {}
+ recentlyClosedAgentStatusTabIds: {},
+ recentlyRetiredAgentStatusPaneKeys: {}
})
expect(result.toRetain).toEqual([])
@@ -186,7 +197,8 @@ describe('collectRetainedAgentsOnDisappear', () => {
currentAgents: new Map(),
retainedAgentsByPaneKey: {},
retentionSuppressedPaneKeys: { 'tab-1:1': true },
- recentlyClosedAgentStatusTabIds: {}
+ recentlyClosedAgentStatusTabIds: {},
+ recentlyRetiredAgentStatusPaneKeys: {}
})
expect(result.toRetain).toEqual([])
@@ -203,7 +215,8 @@ describe('collectRetainedAgentsOnDisappear', () => {
currentAgents: new Map(),
retainedAgentsByPaneKey: {},
retentionSuppressedPaneKeys: {},
- recentlyClosedAgentStatusTabIds: { 'tab-1': true }
+ recentlyClosedAgentStatusTabIds: { 'tab-1': true },
+ recentlyRetiredAgentStatusPaneKeys: {}
})
expect(result.toRetain).toEqual([])
@@ -245,4 +258,52 @@ describe('useRetainedAgentsSync', () => {
expect(state.retainedAgentsByPaneKey[row.paneKey]).toBeUndefined()
hook.unmount()
})
+
+ it('does not leave a ghost row when a done pane detaches into another tab', async () => {
+ const repo = makeRepo()
+ const worktree = makeWorktree()
+ const leafId = '11111111-1111-4111-8111-111111111111'
+ const sourceTab = makeTab({ id: 'tab-source', ptyId: 'pty-a' })
+ const targetTab = makeTab({ id: 'tab-target' })
+ const sourcePaneKey = makePaneKey(sourceTab.id, leafId)
+ const targetPaneKey = makePaneKey(targetTab.id, leafId)
+ const row = makeAgentRow({ paneKey: sourcePaneKey, state: 'done' })
+ useAppStore.setState({
+ repos: [repo],
+ worktreesByRepo: { [repo.id]: [worktree] },
+ tabsByWorktree: { [worktree.id]: [sourceTab, targetTab] },
+ ptyIdsByTabId: { [sourceTab.id]: ['pty-a'], [targetTab.id]: [] },
+ agentStatusByPaneKey: { [sourcePaneKey]: { ...row.entry, tabId: sourceTab.id } },
+ agentStatusEpoch: initialAppState.agentStatusEpoch + 1
+ })
+ const hook = renderHook(() => useRetainedAgentsSync())
+ await act(async () => {
+ await Promise.resolve()
+ })
+
+ act(() => {
+ useAppStore.getState().syncPaneDetachPtyOwnership({
+ detachedLeafId: leafId,
+ detachedPtyId: 'pty-a',
+ sourceLayout: {
+ root: null,
+ activeLeafId: null,
+ expandedLeafId: null,
+ ptyIdsByLeafId: {}
+ },
+ sourceTabId: sourceTab.id,
+ targetTabId: targetTab.id
+ })
+ })
+ await act(async () => {
+ await Promise.resolve()
+ })
+
+ const state = useAppStore.getState()
+ expect(state.agentStatusByPaneKey[targetPaneKey]?.state).toBe('done')
+ expect(state.retainedAgentsByPaneKey[sourcePaneKey]).toBeUndefined()
+ expect(state.retainedAgentsByPaneKey[targetPaneKey]).toBeUndefined()
+ expect(state.retentionSuppressedPaneKeys[sourcePaneKey]).toBeUndefined()
+ hook.unmount()
+ })
})
diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/useRetainedAgents.ts
index c151269551a..61cd0f30ce4 100644
--- a/src/renderer/src/components/dashboard/useRetainedAgents.ts
+++ b/src/renderer/src/components/dashboard/useRetainedAgents.ts
@@ -4,7 +4,9 @@ import { useAppStore } from '@/store'
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
import type { DashboardAgentRow } from './useDashboardData'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
-import type { Repo, TerminalTab, Worktree } from '../../../../shared/types'
+import type { FolderWorkspace, Repo, TerminalTab, Worktree } from '../../../../shared/types'
+import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
+import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
@@ -22,6 +24,7 @@ type RetainedAgentSnapshot = Map
+ folderWorkspaces: FolderWorkspace[]
tabsByWorktree: Record
agentStatusByPaneKey: Record
}
@@ -37,6 +40,7 @@ function paneKeyTabId(paneKey: string): string | null {
function buildLiveTabIndex(args: {
repos: Repo[]
worktreesByRepo: Record
+ folderWorkspaces: FolderWorkspace[]
tabsByWorktree: Record
}): {
existingWorktreeIds: Set
@@ -59,6 +63,17 @@ function buildLiveTabIndex(args: {
}
}
+ for (const folderWorkspace of args.folderWorkspaces) {
+ if (folderWorkspace.isArchived) {
+ continue
+ }
+ const workspaceKey = folderWorkspaceKey(folderWorkspace.id)
+ existingWorktreeIds.add(workspaceKey)
+ for (const tab of args.tabsByWorktree[workspaceKey] ?? []) {
+ tabIndex.set(tab.id, { tab, worktreeId: workspaceKey })
+ }
+ }
+
return { existingWorktreeIds, tabIndex }
}
@@ -69,6 +84,7 @@ function agentStartedAt(entry: AgentStatusEntry): number {
export function buildRetainedAgentsSyncSnapshot(args: RetainedAgentsSyncSnapshotInputs): {
currentAgents: RetainedAgentSnapshot
existingWorktreeIds: Set
+ tabIndex: Map
} {
const { existingWorktreeIds, tabIndex } = buildLiveTabIndex(args)
const currentAgents: RetainedAgentSnapshot = new Map()
@@ -99,23 +115,33 @@ export function buildRetainedAgentsSyncSnapshot(args: RetainedAgentsSyncSnapshot
})
}
- return { currentAgents, existingWorktreeIds }
+ return { currentAgents, existingWorktreeIds, tabIndex }
}
export function useRetainedAgentsSync(): void {
const retainAgents = useAppStore((s) => s.retainAgents)
const pruneRetainedAgents = useAppStore((s) => s.pruneRetainedAgents)
const clearRetentionSuppressedPaneKeys = useAppStore((s) => s.clearRetentionSuppressedPaneKeys)
- const [repos, worktreesByRepo, tabsByWorktree, agentStatusEpoch] = useAppStore(
- useShallow((s) => [s.repos, s.worktreesByRepo, s.tabsByWorktree, s.agentStatusEpoch] as const)
+ const [repos, worktreesByRepo, folderWorkspaces, tabsByWorktree, agentStatusEpoch] = useAppStore(
+ useShallow(
+ (s) =>
+ [
+ s.repos,
+ s.worktreesByRepo,
+ s.folderWorkspaces,
+ s.tabsByWorktree,
+ s.agentStatusEpoch
+ ] as const
+ )
)
const prevAgentsRef = useRef(new Map())
useEffect(() => {
const state = useAppStore.getState()
- const { currentAgents, existingWorktreeIds } = buildRetainedAgentsSyncSnapshot({
+ const { currentAgents, existingWorktreeIds, tabIndex } = buildRetainedAgentsSyncSnapshot({
repos: state.repos,
worktreesByRepo: state.worktreesByRepo,
+ folderWorkspaces: state.folderWorkspaces,
tabsByWorktree: state.tabsByWorktree,
agentStatusByPaneKey: state.agentStatusByPaneKey,
now: Date.now()
@@ -132,7 +158,9 @@ export function useRetainedAgentsSync(): void {
currentAgents,
retainedAgentsByPaneKey: retainedNow,
retentionSuppressedPaneKeys,
- recentlyClosedAgentStatusTabIds: state.recentlyClosedAgentStatusTabIds
+ recentlyClosedAgentStatusTabIds: state.recentlyClosedAgentStatusTabIds,
+ recentlyRetiredAgentStatusPaneKeys: state.recentlyRetiredAgentStatusPaneKeys,
+ tabIndex
})
// Why: batch retention into a single store mutation. Looping retainAgent
// would trigger N set(...) calls and N subscriber notifications when
@@ -149,6 +177,7 @@ export function useRetainedAgentsSync(): void {
}, [
repos,
worktreesByRepo,
+ folderWorkspaces,
tabsByWorktree,
agentStatusEpoch,
retainAgents,
@@ -157,12 +186,40 @@ export function useRetainedAgentsSync(): void {
])
}
+function sameAgentRun(
+ previous: { row: DashboardAgentRow },
+ current: { row: DashboardAgentRow }
+): boolean {
+ // Why: resume identity arrives on its own IPC event, so one side can be stamped
+ // while the other is not. Only a session present on BOTH sides is decisive.
+ const previousSession = previous.row.entry.providerSession
+ const currentSession = current.row.entry.providerSession
+ if (previousSession && currentSession) {
+ return previousSession.key === currentSession.key && previousSession.id === currentSession.id
+ }
+ // Why: terminalHandle identifies the TERMINAL, not the run — a later agent started
+ // in the same pty inherits it, so it only corroborates a matching run identity.
+ // It is absent for ordinary local PTY agents, so it cannot be required.
+ const previousHandle = previous.row.entry.terminalHandle
+ const currentHandle = current.row.entry.terminalHandle
+ if (previousHandle && currentHandle && previousHandle !== currentHandle) {
+ return false
+ }
+ return (
+ previous.row.startedAt === current.row.startedAt &&
+ previous.row.agentType === current.row.agentType
+ )
+}
+
export function collectRetainedAgentsOnDisappear(args: {
previousAgents: Map
currentAgents: Map
retainedAgentsByPaneKey: Record
retentionSuppressedPaneKeys: Record
recentlyClosedAgentStatusTabIds: Record
+ recentlyRetiredAgentStatusPaneKeys: Record
+ /** Live tabs by id; supplies the real destination tab when a pane key transferred. */
+ tabIndex?: Map
}): {
toRetain: RetainedAgentEntry[]
consumedSuppressedPaneKeys: string[]
@@ -174,21 +231,51 @@ export function collectRetainedAgentsOnDisappear(args: {
if (args.currentAgents.has(paneKey)) {
continue
}
+ const transferredPaneKey = resolveAgentPaneAuthorityKey(paneKey)
+ const migrated =
+ transferredPaneKey === paneKey ? undefined : args.currentAgents.get(transferredPaneKey)
+ if (migrated && sameAgentRun(prev, migrated)) {
+ continue
+ }
+ // Why: a different run already occupies the transferred key, so this row keeps
+ // its own key rather than colliding with the live one.
+ const ownerPaneKey = migrated ? paneKey : transferredPaneKey
+ if (
+ args.recentlyRetiredAgentStatusPaneKeys[paneKey] ||
+ args.recentlyRetiredAgentStatusPaneKeys[ownerPaneKey]
+ ) {
+ continue
+ }
// Why: skip only when the retained snapshot is for the SAME (or newer) run.
// A reused paneKey (same tab+pane, fresh agent start after a prior run was
// retained) produces a newer startedAt — we must overwrite so stale
// completion data doesn't linger forever for the reused pane.
- const alreadyRetained = args.retainedAgentsByPaneKey[paneKey]
+ const alreadyRetained = args.retainedAgentsByPaneKey[ownerPaneKey]
if (alreadyRetained && alreadyRetained.startedAt >= prev.row.startedAt) {
continue
}
- if (args.retentionSuppressedPaneKeys[paneKey]) {
- consumedSuppressedPaneKeys.push(paneKey)
+ const suppressedPaneKey = args.retentionSuppressedPaneKeys[paneKey]
+ ? paneKey
+ : args.retentionSuppressedPaneKeys[ownerPaneKey]
+ ? ownerPaneKey
+ : null
+ if (suppressedPaneKey) {
+ consumedSuppressedPaneKeys.push(suppressedPaneKey)
continue
}
+ // Why: the row must land on the surface that owns the pane now — a detach
+ // followed by a PTY exit before the next sync would otherwise retain it
+ // under the abandoned source tab.
+ const ownerTabId = paneKeyTabId(ownerPaneKey) ?? prev.row.tab.id
+ // Why: prefer the real destination tab — reusing the source tab's title and
+ // launchAgent under a different id would mislabel the retained row.
+ const ownerTab =
+ ownerTabId === prev.row.tab.id
+ ? prev.row.tab
+ : (args.tabIndex?.get(ownerTabId)?.tab ?? { ...prev.row.tab, id: ownerTabId })
// Why: PTY exit can remove the live row before closeTab plants a suppressor;
// the closed-tab marker prevents re-retention.
- if (args.recentlyClosedAgentStatusTabIds[prev.row.tab.id]) {
+ if (args.recentlyClosedAgentStatusTabIds[ownerTabId]) {
continue
}
// Why: only keep a sticky snapshot when the agent finished cleanly
@@ -201,9 +288,12 @@ export function collectRetainedAgentsOnDisappear(args: {
continue
}
toRetain.push({
- entry: prev.row.entry,
+ entry:
+ ownerPaneKey === paneKey
+ ? prev.row.entry
+ : { ...prev.row.entry, paneKey: ownerPaneKey, tabId: ownerTabId },
worktreeId: prev.worktreeId,
- tab: prev.row.tab,
+ tab: ownerTab,
agentType: prev.row.agentType,
startedAt: prev.row.startedAt
})
diff --git a/src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts b/src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts
index 16282a9ff0b..3d33575d649 100644
--- a/src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts
+++ b/src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts
@@ -4,12 +4,37 @@ import {
type AgentStatusEntry,
type AgentStatusState
} from '../../../../shared/agent-status-types'
-import type { Repo, TerminalTab, Worktree } from '../../../../shared/types'
+import type { FolderWorkspace, Repo, TerminalTab, Worktree } from '../../../../shared/types'
+import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { buildRetainedAgentsSyncSnapshot } from './useRetainedAgents'
const ACTIVE_PANE_KEY = makePaneKey('tab-active', '22222222-2222-4222-8222-222222222222')
const ARCHIVED_PANE_KEY = makePaneKey('tab-archived', '33333333-3333-4333-8333-333333333333')
+const FOLDER_PANE_KEY = makePaneKey('tab-folder', '44444444-4444-4444-8444-444444444444')
+const ARCHIVED_FOLDER_PANE_KEY = makePaneKey(
+ 'tab-folder-archived',
+ '55555555-5555-4555-8555-555555555555'
+)
+
+function makeFolderWorkspace(overrides?: Partial): FolderWorkspace {
+ return {
+ id: 'folder-1',
+ projectGroupId: 'group-1',
+ name: 'Folder',
+ folderPath: '/repo/folder',
+ linkedTask: null,
+ comment: '',
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 0,
+ lastActivityAt: 1,
+ createdAt: 1,
+ updatedAt: 1,
+ ...overrides
+ }
+}
function makeRepo(): Repo {
return {
@@ -90,6 +115,7 @@ describe('buildRetainedAgentsSyncSnapshot', () => {
const snapshot = buildRetainedAgentsSyncSnapshot({
repos: [repo],
worktreesByRepo: { [repo.id]: [activeWorktree, archivedWorktree] },
+ folderWorkspaces: [],
tabsByWorktree: {
[activeWorktree.id]: [activeTab],
[archivedWorktree.id]: [archivedTab]
@@ -115,4 +141,37 @@ describe('buildRetainedAgentsSyncSnapshot', () => {
expect(snapshot.currentAgents.get(ACTIVE_PANE_KEY)?.row.state).toBe('idle')
expect(snapshot.currentAgents.get(ARCHIVED_PANE_KEY)).toBeUndefined()
})
+
+ it('builds live rows for non-archived folder workspaces and skips archived ones', () => {
+ const folderWorkspace = makeFolderWorkspace()
+ const archivedFolderWorkspace = makeFolderWorkspace({ id: 'folder-2', isArchived: true })
+ const workspaceKey = folderWorkspaceKey(folderWorkspace.id)
+ const archivedWorkspaceKey = folderWorkspaceKey(archivedFolderWorkspace.id)
+ const tab = makeTab({ id: 'tab-folder', worktreeId: workspaceKey })
+ const archivedTab = makeTab({ id: 'tab-folder-archived', worktreeId: archivedWorkspaceKey })
+
+ const snapshot = buildRetainedAgentsSyncSnapshot({
+ repos: [],
+ worktreesByRepo: {},
+ folderWorkspaces: [folderWorkspace, archivedFolderWorkspace],
+ tabsByWorktree: { [workspaceKey]: [tab], [archivedWorkspaceKey]: [archivedTab] },
+ agentStatusByPaneKey: {
+ [FOLDER_PANE_KEY]: makeEntry({
+ paneKey: FOLDER_PANE_KEY,
+ state: 'done',
+ updatedAt: 10_000
+ }),
+ [ARCHIVED_FOLDER_PANE_KEY]: makeEntry({
+ paneKey: ARCHIVED_FOLDER_PANE_KEY,
+ state: 'done',
+ updatedAt: 10_000
+ })
+ },
+ now: 10_000
+ })
+
+ expect([...snapshot.existingWorktreeIds]).toEqual([workspaceKey])
+ expect(snapshot.currentAgents.get(FOLDER_PANE_KEY)?.worktreeId).toBe(workspaceKey)
+ expect(snapshot.currentAgents.get(ARCHIVED_FOLDER_PANE_KEY)).toBeUndefined()
+ })
})
diff --git a/src/renderer/src/store/slices/agent-pane-authority.test.ts b/src/renderer/src/store/slices/agent-pane-authority.test.ts
index a37f3057625..f456688be94 100644
--- a/src/renderer/src/store/slices/agent-pane-authority.test.ts
+++ b/src/renderer/src/store/slices/agent-pane-authority.test.ts
@@ -1,8 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import {
+ countAgentPaneAuthorityAliasesForTests,
+ forgetAgentPaneAuthorityAliasesByTabIds,
resetAgentPaneAuthorityAliasesForTests,
- resolveAgentPaneAuthorityKey
+ resolveAgentPaneAuthorityKey,
+ transferAgentPaneAuthorityAlias
} from './agent-pane-authority'
import { createTestStore } from './store-test-helpers'
@@ -97,6 +100,7 @@ describe('agent pane authority', () => {
store.getState().dropAgentStatusByTabPrefix('tab-source')
expect(resolveAgentPaneAuthorityKey(SOURCE)).toBe(FINAL)
+ expect(resolveAgentPaneAuthorityKey(TARGET)).toBe(FINAL)
expect(store.getState().sleepingAgentSessionsByPaneKey[FINAL]).toMatchObject({
paneKey: FINAL,
tabId: 'tab-final',
@@ -122,4 +126,47 @@ describe('agent pane authority', () => {
expect(store.getState().agentStatusByPaneKey[FINAL]).toBeUndefined()
expect(store.getState().recentlyRetiredAgentStatusPaneKeys[SOURCE]).toBe(true)
})
+
+ it('forgets aliases for purged tabs and caps unbounded detach churn', () => {
+ const leafId = '66666666-6666-4666-8666-666666666666'
+ transferAgentPaneAuthorityAlias({
+ fromPaneKey: makePaneKey('tab-purged', leafId),
+ toPaneKey: makePaneKey('tab-kept', leafId),
+ ptyId: 'pty-1'
+ })
+ expect(countAgentPaneAuthorityAliasesForTests()).toBe(1)
+
+ forgetAgentPaneAuthorityAliasesByTabIds(['tab-kept'])
+ expect(countAgentPaneAuthorityAliasesForTests()).toBe(0)
+
+ // Why: pty exit and workspace purge do not retire, so the cap is the backstop.
+ for (let index = 0; index < 600; index += 1) {
+ transferAgentPaneAuthorityAlias({
+ fromPaneKey: makePaneKey(`tab-from-${index}`, leafId),
+ toPaneKey: makePaneKey(`tab-to-${index}`, leafId),
+ ptyId: `pty-${index}`
+ })
+ }
+ expect(countAgentPaneAuthorityAliasesForTests()).toBeLessThanOrEqual(512)
+ expect(resolveAgentPaneAuthorityKey(makePaneKey('tab-from-599', leafId))).toBe(
+ makePaneKey('tab-to-599', leafId)
+ )
+ })
+
+ it('clears the alias when a pane moves back to a key it previously left', () => {
+ const store = createTestStore()
+ store.getState().setAgentStatus(SOURCE, { state: 'working', prompt: 'source' })
+
+ store
+ .getState()
+ .transferAgentPaneAuthority({ fromPaneKey: SOURCE, toPaneKey: TARGET, ptyId: 'pty-1' })
+ store
+ .getState()
+ .transferAgentPaneAuthority({ fromPaneKey: TARGET, toPaneKey: SOURCE, ptyId: 'pty-1' })
+
+ expect(resolveAgentPaneAuthorityKey(SOURCE)).toBe(SOURCE)
+ expect(resolveAgentPaneAuthorityKey(TARGET)).toBe(SOURCE)
+ store.getState().setAgentStatus(SOURCE, { state: 'done', prompt: 'back home' })
+ expect(store.getState().agentStatusByPaneKey[SOURCE]?.prompt).toBe('back home')
+ })
})
diff --git a/src/renderer/src/store/slices/agent-pane-authority.ts b/src/renderer/src/store/slices/agent-pane-authority.ts
index c5f25d0ec36..f019fc04fcf 100644
--- a/src/renderer/src/store/slices/agent-pane-authority.ts
+++ b/src/renderer/src/store/slices/agent-pane-authority.ts
@@ -7,6 +7,21 @@ type AgentPaneAuthorityAlias = {
const aliasesByPhysicalPaneKey = new Map()
+// Why: teardown paths that never retire (pty exit, workspace purge) would otherwise
+// leak an entry per detach for the renderer's lifetime; evict oldest-first.
+const MAX_AGENT_PANE_AUTHORITY_ALIASES = 512
+
+function evictOldestAgentPaneAuthorityAliases(): void {
+ // Why: mirrors the main-process alias bound (boundPaneKeyAliases) — insertion-order eviction.
+ while (aliasesByPhysicalPaneKey.size > MAX_AGENT_PANE_AUTHORITY_ALIASES) {
+ const oldestPaneKey = aliasesByPhysicalPaneKey.keys().next().value
+ if (!oldestPaneKey) {
+ break
+ }
+ aliasesByPhysicalPaneKey.delete(oldestPaneKey)
+ }
+}
+
export type AgentPaneAuthorityTransfer = {
physicalPaneKey: string
previousOwnerPaneKey: string
@@ -38,14 +53,28 @@ export function transferAgentPaneAuthorityAlias(args: {
}
}
const ptyId = args.ptyId?.trim() || aliasesByPhysicalPaneKey.get(physicalPaneKey)?.ptyId || null
- if (physicalPaneKey !== args.toPaneKey) {
- // Why: the process keeps posting its original ORCA_PANE_KEY after detach;
- // one physical-to-owner alias keeps chained moves on the current surface.
- aliasesByPhysicalPaneKey.set(physicalPaneKey, {
+ // Why: every key that resolved to the old owner must follow the move in one hop,
+ // or a chained detach leaves an intermediate key routing to a dead pane.
+ const formerOwnerPaneKeys = new Set([physicalPaneKey, previousOwnerPaneKey])
+ for (const [candidatePhysicalPaneKey, alias] of aliasesByPhysicalPaneKey) {
+ if (alias.ownerPaneKey === previousOwnerPaneKey) {
+ formerOwnerPaneKeys.add(candidatePhysicalPaneKey)
+ }
+ }
+ for (const formerOwnerPaneKey of formerOwnerPaneKeys) {
+ if (formerOwnerPaneKey === args.toPaneKey) {
+ // Why: the pane came back to this key, so it owns itself again.
+ aliasesByPhysicalPaneKey.delete(formerOwnerPaneKey)
+ continue
+ }
+ // Why: re-insert so an alias refreshed by a later move is not the eviction victim.
+ aliasesByPhysicalPaneKey.delete(formerOwnerPaneKey)
+ aliasesByPhysicalPaneKey.set(formerOwnerPaneKey, {
ownerPaneKey: args.toPaneKey,
ptyId
})
}
+ evictOldestAgentPaneAuthorityAliases()
return {
physicalPaneKey,
previousOwnerPaneKey,
@@ -81,6 +110,28 @@ export function retireAgentPaneAuthorityAliasesByOwnerTab(tabId: string): string
return [...retiredPaneKeys]
}
+/** Drop aliases whose physical or owner pane belongs to a purged tab. */
+export function forgetAgentPaneAuthorityAliasesByTabIds(tabIds: Iterable): void {
+ const doomedTabIds = tabIds instanceof Set ? tabIds : new Set(tabIds)
+ if (doomedTabIds.size === 0) {
+ return
+ }
+ for (const [physicalPaneKey, alias] of aliasesByPhysicalPaneKey) {
+ const physicalTabId = parsePaneKey(physicalPaneKey)?.tabId
+ const ownerTabId = parsePaneKey(alias.ownerPaneKey)?.tabId
+ if (
+ (physicalTabId && doomedTabIds.has(physicalTabId)) ||
+ (ownerTabId && doomedTabIds.has(ownerTabId))
+ ) {
+ aliasesByPhysicalPaneKey.delete(physicalPaneKey)
+ }
+ }
+}
+
+export function countAgentPaneAuthorityAliasesForTests(): number {
+ return aliasesByPhysicalPaneKey.size
+}
+
export function resetAgentPaneAuthorityAliasesForTests(): void {
aliasesByPhysicalPaneKey.clear()
}
diff --git a/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts b/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts
index 9052399a4e6..c67d1a71528 100644
--- a/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts
+++ b/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts
@@ -46,6 +46,13 @@ const mockApi = {
globalThis.window = { api: mockApi }
import { createTestStore, seedStore, makeWorktree, makeTab } from './store-test-helpers'
+import { makePaneKey } from '../../../../shared/stable-pane-id'
+import {
+ countAgentPaneAuthorityAliasesForTests,
+ resetAgentPaneAuthorityAliasesForTests,
+ resolveAgentPaneAuthorityKey,
+ transferAgentPaneAuthorityAlias
+} from './agent-pane-authority'
const WT = 'repo1::/path/wt1'
const TAB = 'tab-1'
@@ -138,4 +145,27 @@ describe('bulk worktree purge evicts pane-scoped agent/unread/input maps (leak r
expect(s.unreadTerminalTabs[OTHER_TAB]).toBe(true)
expect(s.lastTerminalInputAtByPaneKey[OTHER_PANE]).toBe(2)
})
+
+ it('purgeWorktreeTerminalState forgets pane-authority aliases for the removed tabs', () => {
+ resetAgentPaneAuthorityAliasesForTests()
+ const store = createTestStore()
+ const leafId = '77777777-7777-4777-8777-777777777777'
+ const sourcePaneKey = makePaneKey('tab-detached', leafId)
+ const ownerPaneKey = makePaneKey(TAB, leafId)
+ seedPaneState(store)
+ transferAgentPaneAuthorityAlias({
+ fromPaneKey: sourcePaneKey,
+ toPaneKey: ownerPaneKey,
+ ptyId: 'pty-1'
+ })
+ expect(resolveAgentPaneAuthorityKey(sourcePaneKey)).toBe(ownerPaneKey)
+
+ store.getState().purgeWorktreeTerminalState([WT])
+
+ // Why: the alias registry outlives the store maps, so a purged tab must not
+ // keep routing status posts to a pane that no longer exists.
+ expect(resolveAgentPaneAuthorityKey(sourcePaneKey)).toBe(sourcePaneKey)
+ expect(countAgentPaneAuthorityAliasesForTests()).toBe(0)
+ resetAgentPaneAuthorityAliasesForTests()
+ })
})
diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts
index 90603abb19b..563a75c617b 100644
--- a/src/renderer/src/store/slices/agent-status.ts
+++ b/src/renderer/src/store/slices/agent-status.ts
@@ -1303,69 +1303,55 @@ export const createAgentStatusSlice: StateCreator {
- // Plant a one-shot suppressor on the source key so useRetainedAgentsSync —
- // which only sees `from` vanish, not the migration — can't resurrect a ghost row.
- const movedSuppressors = movePaneKeyedRecord(s.retentionSuppressedPaneKeys, from, to)
- // Guard on fromWasLive: a suppressor is consumed only on a live→gone transition, else it leaks.
- const fromWasLive = from in s.agentStatusByPaneKey
- const retentionSuppressedPaneKeys: Record =
- fromWasLive && !(from in movedSuppressors)
- ? { ...movedSuppressors, [from]: true }
- : movedSuppressors
- return {
- agentStatusByPaneKey: movePaneKeyedRecord(s.agentStatusByPaneKey, from, to, (entry) => ({
+ set((s) => ({
+ agentStatusByPaneKey: movePaneKeyedRecord(s.agentStatusByPaneKey, from, to, (entry) => ({
+ ...entry,
+ paneKey: to,
+ tabId: targetTabId
+ })),
+ // Why: retention/sidebar consumers gate on the epoch; a moved live row is a
+ // pane-key change they must observe, not a silent remap.
+ ...(from in s.agentStatusByPaneKey
+ ? { agentStatusEpoch: s.agentStatusEpoch + 1, sortEpoch: s.sortEpoch + 1 }
+ : {}),
+ runtimeAgentOrchestrationByPaneKey: movePaneKeyedRecord(
+ s.runtimeAgentOrchestrationByPaneKey,
+ from,
+ to
+ ),
+ retainedAgentsByPaneKey: movePaneKeyedRecord(
+ s.retainedAgentsByPaneKey,
+ from,
+ to,
+ (retained) => ({
+ ...retained,
+ entry: { ...retained.entry, paneKey: to, tabId: targetTabId },
+ tab: targetTabId ? { ...retained.tab, id: targetTabId } : retained.tab
+ })
+ ),
+ sleepingAgentSessionsByPaneKey: movePaneKeyedRecord(
+ s.sleepingAgentSessionsByPaneKey,
+ from,
+ to,
+ (record) => ({ ...record, paneKey: to, tabId: targetTabId })
+ ),
+ agentLaunchConfigByPaneKey: movePaneKeyedRecord(
+ s.agentLaunchConfigByPaneKey,
+ from,
+ to,
+ (entry) => ({
...entry,
- paneKey: to,
- tabId: targetTabId
- })),
- runtimeAgentOrchestrationByPaneKey: movePaneKeyedRecord(
- s.runtimeAgentOrchestrationByPaneKey,
- from,
- to
- ),
- retainedAgentsByPaneKey: movePaneKeyedRecord(
- s.retainedAgentsByPaneKey,
- from,
- to,
- (retained) => ({
- ...retained,
- entry: { ...retained.entry, paneKey: to, tabId: targetTabId },
- tab: targetTabId ? { ...retained.tab, id: targetTabId } : retained.tab
- })
- ),
- sleepingAgentSessionsByPaneKey: movePaneKeyedRecord(
- s.sleepingAgentSessionsByPaneKey,
- from,
- to,
- (record) => ({ ...record, paneKey: to, tabId: targetTabId })
- ),
- agentLaunchConfigByPaneKey: movePaneKeyedRecord(
- s.agentLaunchConfigByPaneKey,
- from,
- to,
- (entry) => ({
- ...entry,
- identity: { ...entry.identity, tabId: targetTabId, leafId: targetLeafId }
- })
- ),
- acknowledgedAgentsByPaneKey: movePaneKeyedRecord(s.acknowledgedAgentsByPaneKey, from, to),
- paneForegroundAgentByPaneKey: movePaneKeyedRecord(
- s.paneForegroundAgentByPaneKey,
- from,
- to
- ),
- unreadTerminalPanes: movePaneKeyedRecord(s.unreadTerminalPanes, from, to),
- unreadAgentCompletionPanes: movePaneKeyedRecord(s.unreadAgentCompletionPanes, from, to),
- lastTerminalInputAtByPaneKey: movePaneKeyedRecord(
- s.lastTerminalInputAtByPaneKey,
- from,
- to
- ),
- cacheTimerByKey: movePaneKeyedRecord(s.cacheTimerByKey, from, to),
- retentionSuppressedPaneKeys
- }
- })
+ identity: { ...entry.identity, tabId: targetTabId, leafId: targetLeafId }
+ })
+ ),
+ acknowledgedAgentsByPaneKey: movePaneKeyedRecord(s.acknowledgedAgentsByPaneKey, from, to),
+ paneForegroundAgentByPaneKey: movePaneKeyedRecord(s.paneForegroundAgentByPaneKey, from, to),
+ unreadTerminalPanes: movePaneKeyedRecord(s.unreadTerminalPanes, from, to),
+ unreadAgentCompletionPanes: movePaneKeyedRecord(s.unreadAgentCompletionPanes, from, to),
+ lastTerminalInputAtByPaneKey: movePaneKeyedRecord(s.lastTerminalInputAtByPaneKey, from, to),
+ cacheTimerByKey: movePaneKeyedRecord(s.cacheTimerByKey, from, to),
+ retentionSuppressedPaneKeys: movePaneKeyedRecord(s.retentionSuppressedPaneKeys, from, to)
+ }))
if (typeof window !== 'undefined') {
window.api?.agentStatus?.transferPaneAuthority?.({
fromPaneKey: from,
diff --git a/src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts b/src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts
index 6e9c0541e17..a824c15f915 100644
--- a/src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts
+++ b/src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts
@@ -65,6 +65,8 @@ describe('syncPaneDetachPtyOwnership agent identity', () => {
}
})
+ const epochBeforeDetach = store.getState().agentStatusEpoch
+
store.getState().syncPaneDetachPtyOwnership({
detachedLeafId,
detachedPtyId: 'pty-droid',
@@ -103,9 +105,10 @@ describe('syncPaneDetachPtyOwnership agent identity', () => {
tabId: targetTabId,
providerSession: { key: 'session_id', id: 'session-1' }
})
- // Why: detach plants a one-shot suppressor on the source key so the sidebar
- // retention hook can't resurrect the migrated pane as an unclickable ghost row.
- expect(state.retentionSuppressedPaneKeys[sourcePaneKey]).toBe(true)
+ expect(state.retentionSuppressedPaneKeys[sourcePaneKey]).toBeUndefined()
+ expect(state.retentionSuppressedPaneKeys[targetPaneKey]).toBeUndefined()
+ // Why: the retention effect only reruns on an epoch bump; a moved live row must trigger it.
+ expect(state.agentStatusEpoch).toBeGreaterThan(epochBeforeDetach)
expect(state.paneForegroundAgentByPaneKey[siblingPaneKey]).toEqual({
agent: 'antigravity',
shellForeground: false
diff --git a/src/renderer/src/store/slices/terminal-pane-detach-agent-retention.test.ts b/src/renderer/src/store/slices/terminal-pane-detach-agent-retention.test.ts
index 3c0cabdf052..257753fa58d 100644
--- a/src/renderer/src/store/slices/terminal-pane-detach-agent-retention.test.ts
+++ b/src/renderer/src/store/slices/terminal-pane-detach-agent-retention.test.ts
@@ -1,48 +1,86 @@
-import { describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { collectRetainedAgentsOnDisappear } from '@/components/dashboard/useRetainedAgents'
+import {
+ resetAgentPaneAuthorityAliasesForTests,
+ resolveAgentPaneAuthorityKey
+} from './agent-pane-authority'
import { createTestStore, makeTab, makeWorktree, seedStore } from './store-test-helpers'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
+import type { TerminalTab } from '../../../../shared/types'
import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData'
-// Detaching a completed split pane migrates the paneKey `sourceTab:leaf → targetTab:leaf`;
-// these tests pin the store→hook contract that stops the sidebar resurrecting a ghost row.
-
const WORKTREE_ID = 'repo::/repo/worktree'
const SOURCE_TAB = 'tab-source'
const TARGET_TAB = 'tab-target'
+const FINAL_TAB = 'tab-final'
const LEAF = '11111111-1111-4111-8111-111111111111'
const SIBLING_LEAF = '22222222-2222-4222-8222-222222222222'
const SOURCE_PANE_KEY = makePaneKey(SOURCE_TAB, LEAF)
const TARGET_PANE_KEY = makePaneKey(TARGET_TAB, LEAF)
+const FINAL_PANE_KEY = makePaneKey(FINAL_TAB, LEAF)
-function makeDoneEntry(paneKey: string, tabId: string): AgentStatusEntry {
+beforeEach(() => {
+ resetAgentPaneAuthorityAliasesForTests()
+})
+
+afterEach(() => {
+ resetAgentPaneAuthorityAliasesForTests()
+})
+
+function makeDoneEntry(args: {
+ paneKey: string
+ tabId: string
+ startedAt: number
+ agentType: AgentStatusEntry['agentType']
+ terminalHandle?: string
+ providerSession?: AgentStatusEntry['providerSession']
+}): AgentStatusEntry {
return {
state: 'done',
prompt: 'Fix it',
- updatedAt: 100,
- stateStartedAt: 100,
- paneKey,
- tabId,
+ updatedAt: args.startedAt,
+ stateStartedAt: args.startedAt,
+ paneKey: args.paneKey,
+ tabId: args.tabId,
terminalTitle: 'Claude',
stateHistory: [],
- agentType: 'claude',
+ agentType: args.agentType,
+ terminalHandle: args.terminalHandle,
+ providerSession: args.providerSession,
interrupted: false
}
}
-function makeRow(paneKey: string, tabId: string): DashboardAgentRow {
+function makeRow(
+ paneKey: string,
+ tabId: string,
+ startedAt = 100,
+ overrides?: {
+ agentType?: AgentStatusEntry['agentType']
+ terminalHandle?: string
+ providerSession?: AgentStatusEntry['providerSession']
+ }
+): DashboardAgentRow {
+ const agentType = overrides?.agentType ?? 'claude'
return {
paneKey,
- entry: makeDoneEntry(paneKey, tabId),
+ entry: makeDoneEntry({
+ paneKey,
+ tabId,
+ startedAt,
+ agentType,
+ terminalHandle: overrides?.terminalHandle,
+ providerSession: overrides?.providerSession
+ }),
tab: makeTab({ id: tabId, worktreeId: WORKTREE_ID }),
- agentType: 'claude',
+ agentType,
state: 'done',
- startedAt: 100
+ startedAt
}
}
-function detachDoneAgentAndReadStore() {
+function createDetachStore() {
const store = createTestStore()
seedStore(store, {
worktreesByRepo: {
@@ -51,16 +89,24 @@ function detachDoneAgentAndReadStore() {
tabsByWorktree: {
[WORKTREE_ID]: [
makeTab({ id: SOURCE_TAB, worktreeId: WORKTREE_ID, ptyId: 'pty-a' }),
- makeTab({ id: TARGET_TAB, worktreeId: WORKTREE_ID, ptyId: null })
+ makeTab({ id: TARGET_TAB, worktreeId: WORKTREE_ID, ptyId: null }),
+ makeTab({ id: FINAL_TAB, worktreeId: WORKTREE_ID, ptyId: null })
]
},
- ptyIdsByTabId: { [SOURCE_TAB]: ['pty-a', 'pty-sibling'], [TARGET_TAB]: [] }
- })
- store.getState().setAgentStatus(SOURCE_PANE_KEY, {
- state: 'done',
- prompt: 'Fix it',
- agentType: 'claude'
+ ptyIdsByTabId: {
+ [SOURCE_TAB]: ['pty-a', 'pty-sibling'],
+ [TARGET_TAB]: [],
+ [FINAL_TAB]: []
+ }
})
+ return store
+}
+
+function detach(
+ store: ReturnType,
+ sourceTabId: string,
+ targetTabId: string
+) {
store.getState().syncPaneDetachPtyOwnership({
detachedLeafId: LEAF,
detachedPtyId: 'pty-a',
@@ -70,70 +116,317 @@ function detachDoneAgentAndReadStore() {
expandedLeafId: null,
ptyIdsByLeafId: { [SIBLING_LEAF]: 'pty-sibling' }
},
- sourceTabId: SOURCE_TAB,
- targetTabId: TARGET_TAB
+ sourceTabId,
+ targetTabId
+ })
+}
+
+function collect(args: {
+ currentPaneKey?: string
+ currentTabId?: string
+ currentStartedAt?: number
+ currentAgentType?: AgentStatusEntry['agentType']
+ terminalHandle?: string
+ previousProviderSession?: AgentStatusEntry['providerSession']
+ currentProviderSession?: AgentStatusEntry['providerSession']
+ retiredPaneKeys?: Record
+ tabIndex?: Map
+}) {
+ return collectRetainedAgentsOnDisappear({
+ previousAgents: new Map([
+ [
+ SOURCE_PANE_KEY,
+ {
+ row: makeRow(SOURCE_PANE_KEY, SOURCE_TAB, 100, {
+ terminalHandle: args.terminalHandle,
+ providerSession: args.previousProviderSession
+ }),
+ worktreeId: WORKTREE_ID
+ }
+ ]
+ ]),
+ currentAgents:
+ args.currentPaneKey && args.currentTabId
+ ? new Map([
+ [
+ args.currentPaneKey,
+ {
+ row: makeRow(args.currentPaneKey, args.currentTabId, args.currentStartedAt, {
+ agentType: args.currentAgentType,
+ terminalHandle: args.terminalHandle,
+ providerSession: args.currentProviderSession
+ }),
+ worktreeId: WORKTREE_ID
+ }
+ ]
+ ])
+ : new Map(),
+ retainedAgentsByPaneKey: {},
+ retentionSuppressedPaneKeys: {},
+ recentlyClosedAgentStatusTabIds: {},
+ recentlyRetiredAgentStatusPaneKeys: args.retiredPaneKeys ?? {},
+ tabIndex: args.tabIndex
})
- return store.getState()
}
describe('detach completed split pane → sidebar retention', () => {
- it('plants a source-key suppressor so the retention hook drops the migrated pane', () => {
- const state = detachDoneAgentAndReadStore()
+ it('recognizes the same run at its transferred authority without a suppressor', () => {
+ const store = createDetachStore()
+ store.getState().setAgentStatus(SOURCE_PANE_KEY, {
+ state: 'done',
+ prompt: 'Fix it',
+ agentType: 'claude'
+ })
+
+ detach(store, SOURCE_TAB, TARGET_TAB)
+ const state = store.getState()
+ const result = collect({ currentPaneKey: TARGET_PANE_KEY, currentTabId: TARGET_TAB })
- // Status migrated to the new tab's pane key.
expect(state.agentStatusByPaneKey[SOURCE_PANE_KEY]).toBeUndefined()
expect(state.agentStatusByPaneKey[TARGET_PANE_KEY]?.state).toBe('done')
-
- // The retention hook sees the old key vanish (prevRef held source, current
- // holds target). Feed it the REAL post-detach suppressor state.
- const result = collectRetainedAgentsOnDisappear({
- previousAgents: new Map([
- [SOURCE_PANE_KEY, { row: makeRow(SOURCE_PANE_KEY, SOURCE_TAB), worktreeId: WORKTREE_ID }]
- ]),
- currentAgents: new Map([
- [TARGET_PANE_KEY, { row: makeRow(TARGET_PANE_KEY, TARGET_TAB), worktreeId: WORKTREE_ID }]
- ]),
- retainedAgentsByPaneKey: {},
- retentionSuppressedPaneKeys: state.retentionSuppressedPaneKeys,
- recentlyClosedAgentStatusTabIds: {}
- })
-
- // No ghost row; the one-shot suppressor is consumed instead of retained.
+ expect(state.retentionSuppressedPaneKeys[SOURCE_PANE_KEY]).toBeUndefined()
expect(result.toRetain).toEqual([])
- expect(result.consumedSuppressedPaneKeys).toEqual([SOURCE_PANE_KEY])
+ expect(result.consumedSuppressedPaneKeys).toEqual([])
})
- it('does not plant a suppressor when the detached pane never had a live agent', () => {
- const store = createTestStore()
- seedStore(store, {
- worktreesByRepo: {
- repo: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo', path: '/repo/worktree' })]
- },
+ it('recognizes the same run after chained transfers', () => {
+ const store = createDetachStore()
+ store.getState().setAgentStatus(SOURCE_PANE_KEY, {
+ state: 'done',
+ prompt: 'Fix it',
+ agentType: 'claude'
+ })
+
+ detach(store, SOURCE_TAB, TARGET_TAB)
+ detach(store, TARGET_TAB, FINAL_TAB)
+
+ expect(collect({ currentPaneKey: FINAL_PANE_KEY, currentTabId: FINAL_TAB }).toRetain).toEqual(
+ []
+ )
+ // Why: every intermediate key must resolve to the CURRENT owner, not the previous hop.
+ expect(resolveAgentPaneAuthorityKey(SOURCE_PANE_KEY)).toBe(FINAL_PANE_KEY)
+ expect(resolveAgentPaneAuthorityKey(TARGET_PANE_KEY)).toBe(FINAL_PANE_KEY)
+ })
+
+ it('keeps every intermediate key routed after a fourth move', () => {
+ const store = createDetachStore()
+ const EXTRA_TAB = 'tab-extra'
+ const extraPaneKey = makePaneKey(EXTRA_TAB, LEAF)
+ store.setState({
tabsByWorktree: {
[WORKTREE_ID]: [
- makeTab({ id: SOURCE_TAB, worktreeId: WORKTREE_ID, ptyId: 'pty-a' }),
- makeTab({ id: TARGET_TAB, worktreeId: WORKTREE_ID, ptyId: null })
+ ...(store.getState().tabsByWorktree[WORKTREE_ID] ?? []),
+ makeTab({ id: EXTRA_TAB, worktreeId: WORKTREE_ID, ptyId: null })
]
- },
- ptyIdsByTabId: { [SOURCE_TAB]: ['pty-a', 'pty-sibling'], [TARGET_TAB]: [] }
+ }
+ })
+ store.getState().setAgentStatus(SOURCE_PANE_KEY, {
+ state: 'done',
+ prompt: 'Fix it',
+ agentType: 'claude'
})
- // No setAgentStatus here: a plain terminal pane with no agent.
- store.getState().syncPaneDetachPtyOwnership({
- detachedLeafId: LEAF,
- detachedPtyId: 'pty-a',
- sourceLayout: {
- root: { type: 'leaf', leafId: SIBLING_LEAF },
- activeLeafId: SIBLING_LEAF,
- expandedLeafId: null,
- ptyIdsByLeafId: { [SIBLING_LEAF]: 'pty-sibling' }
- },
- sourceTabId: SOURCE_TAB,
- targetTabId: TARGET_TAB
+ detach(store, SOURCE_TAB, TARGET_TAB)
+ detach(store, TARGET_TAB, FINAL_TAB)
+ detach(store, FINAL_TAB, EXTRA_TAB)
+
+ for (const formerKey of [SOURCE_PANE_KEY, TARGET_PANE_KEY, FINAL_PANE_KEY]) {
+ expect(resolveAgentPaneAuthorityKey(formerKey)).toBe(extraPaneKey)
+ }
+ store.getState().setAgentStatus(TARGET_PANE_KEY, { state: 'done', prompt: 'late post' })
+ expect(store.getState().agentStatusByPaneKey[extraPaneKey]?.prompt).toBe('late post')
+ })
+
+ it('still retains the source when a different run occupies the transferred key', () => {
+ const store = createDetachStore()
+ store.getState().setAgentStatus(SOURCE_PANE_KEY, {
+ state: 'done',
+ prompt: 'Fix it',
+ agentType: 'claude'
})
- // Why: a suppressor is only consumed on a live→gone transition. The source
- // key was never live, so planting one here would leak it forever.
+ detach(store, SOURCE_TAB, TARGET_TAB)
+ const result = collect({
+ currentPaneKey: TARGET_PANE_KEY,
+ currentTabId: TARGET_TAB,
+ currentStartedAt: 900
+ })
+
+ expect(result.toRetain).toHaveLength(1)
+ expect(result.toRetain[0]?.entry.paneKey).toBe(SOURCE_PANE_KEY)
+ })
+
+ it('still retains when a new agent reuses the pty and inherits its terminal handle', () => {
+ const store = createDetachStore()
+ store.getState().setAgentStatus(SOURCE_PANE_KEY, {
+ state: 'done',
+ prompt: 'Fix it',
+ agentType: 'claude'
+ })
+
+ detach(store, SOURCE_TAB, TARGET_TAB)
+ // Why: terminalHandle is pty-scoped, so a fresh codex run carries the same handle.
+ const result = collect({
+ currentPaneKey: TARGET_PANE_KEY,
+ currentTabId: TARGET_TAB,
+ currentStartedAt: 900,
+ currentAgentType: 'codex',
+ terminalHandle: 'term_shared'
+ })
+
+ expect(result.toRetain).toHaveLength(1)
+ expect(result.toRetain[0]?.entry.paneKey).toBe(SOURCE_PANE_KEY)
+ })
+
+ it('treats differing terminal handles as different runs', () => {
+ const store = createDetachStore()
+ store.getState().setAgentStatus(SOURCE_PANE_KEY, {
+ state: 'done',
+ prompt: 'Fix it',
+ agentType: 'claude'
+ })
+
+ detach(store, SOURCE_TAB, TARGET_TAB)
+ // Why: identical startedAt/agentType can collide, so a differing handle is the
+ // only signal that these are separate terminals.
+ const result = collectRetainedAgentsOnDisappear({
+ previousAgents: new Map([
+ [
+ SOURCE_PANE_KEY,
+ {
+ row: makeRow(SOURCE_PANE_KEY, SOURCE_TAB, 100, { terminalHandle: 'term_source' }),
+ worktreeId: WORKTREE_ID
+ }
+ ]
+ ]),
+ currentAgents: new Map([
+ [
+ TARGET_PANE_KEY,
+ {
+ row: makeRow(TARGET_PANE_KEY, TARGET_TAB, 100, { terminalHandle: 'term_other' }),
+ worktreeId: WORKTREE_ID
+ }
+ ]
+ ]),
+ retainedAgentsByPaneKey: {},
+ retentionSuppressedPaneKeys: {},
+ recentlyClosedAgentStatusTabIds: {},
+ recentlyRetiredAgentStatusPaneKeys: {}
+ })
+
+ expect(result.toRetain).toHaveLength(1)
+ })
+
+ it('recognizes the same run when resume identity lands only after the move', () => {
+ const store = createDetachStore()
+ store.getState().setAgentStatus(SOURCE_PANE_KEY, {
+ state: 'done',
+ prompt: 'Fix it',
+ agentType: 'claude'
+ })
+
+ detach(store, SOURCE_TAB, TARGET_TAB)
+ // Why: recordAgentProviderSession is its own IPC event, so the destination can be
+ // stamped while the pre-move snapshot is not. A one-sided session is not evidence
+ // of a different run.
+ const result = collect({
+ currentPaneKey: TARGET_PANE_KEY,
+ currentTabId: TARGET_TAB,
+ currentProviderSession: { key: 'session_id', id: 'session-1' }
+ })
+
+ expect(result.toRetain).toEqual([])
+ })
+
+ it('treats mismatched resume identities as different runs', () => {
+ const store = createDetachStore()
+ store.getState().setAgentStatus(SOURCE_PANE_KEY, {
+ state: 'done',
+ prompt: 'Fix it',
+ agentType: 'claude'
+ })
+
+ detach(store, SOURCE_TAB, TARGET_TAB)
+ const result = collect({
+ currentPaneKey: TARGET_PANE_KEY,
+ currentTabId: TARGET_TAB,
+ previousProviderSession: { key: 'session_id', id: 'session-1' },
+ currentProviderSession: { key: 'session_id', id: 'session-2' }
+ })
+
+ expect(result.toRetain).toHaveLength(1)
+ })
+
+ it('recognizes a handle-less local pty run at its transferred authority', () => {
+ const store = createDetachStore()
+ store.getState().setAgentStatus(SOURCE_PANE_KEY, {
+ state: 'done',
+ prompt: 'Fix it',
+ agentType: 'claude'
+ })
+
+ detach(store, SOURCE_TAB, TARGET_TAB)
+ // Why: ordinary local PTY statuses route with connectionId only, so terminalHandle
+ // is undefined on both sides — requiring a handle here would resurrect the ghost.
+ const result = collect({ currentPaneKey: TARGET_PANE_KEY, currentTabId: TARGET_TAB })
+
+ expect(result.toRetain[0]?.entry.terminalHandle).toBeUndefined()
+ expect(result.toRetain).toEqual([])
+ })
+
+ it('does not resurrect the source when the transferred owner immediately closes', () => {
+ const store = createDetachStore()
+ store.getState().setAgentStatus(SOURCE_PANE_KEY, {
+ state: 'done',
+ prompt: 'Fix it',
+ agentType: 'claude'
+ })
+
+ detach(store, SOURCE_TAB, TARGET_TAB)
+ store.getState().dropAgentStatusByTabPrefix(TARGET_TAB)
+ const retiredPaneKeys = store.getState().recentlyRetiredAgentStatusPaneKeys
+
+ expect(retiredPaneKeys[SOURCE_PANE_KEY]).toBe(true)
+ expect(retiredPaneKeys[TARGET_PANE_KEY]).toBe(true)
+ expect(collect({ retiredPaneKeys }).toRetain).toEqual([])
+ })
+
+ it('retains at the transferred surface when the destination pty exits before the next sync', () => {
+ const store = createDetachStore()
+ store.getState().setAgentStatus(SOURCE_PANE_KEY, {
+ state: 'done',
+ prompt: 'Fix it',
+ agentType: 'claude'
+ })
+
+ detach(store, SOURCE_TAB, TARGET_TAB)
+ store.getState().removeAgentStatus(TARGET_PANE_KEY)
+
+ // Why: the retention effect coalesced the move and the exit into one pass, so it
+ // only ever sees the source key vanish.
+ const destinationTab = makeTab({
+ id: TARGET_TAB,
+ worktreeId: WORKTREE_ID,
+ title: 'Destination',
+ launchAgent: 'codex'
+ })
+ const retained = collect({
+ tabIndex: new Map([[TARGET_TAB, { tab: destinationTab }]])
+ }).toRetain
+ expect(retained).toHaveLength(1)
+ expect(retained[0]?.entry.paneKey).toBe(TARGET_PANE_KEY)
+ expect(retained[0]?.entry.tabId).toBe(TARGET_TAB)
+ // Why: the row must carry the DESTINATION tab's identity, not the source tab's
+ // title/launchAgent relabeled with a new id — sidebar agent-type resolution reads both.
+ expect(retained[0]?.tab).toBe(destinationTab)
+ expect(retained[0]?.tab.title).toBe('Destination')
+ })
+
+ it('does not create suppressors for a plain terminal detach', () => {
+ const store = createDetachStore()
+
+ detach(store, SOURCE_TAB, TARGET_TAB)
+
expect(store.getState().retentionSuppressedPaneKeys[SOURCE_PANE_KEY]).toBeUndefined()
expect(store.getState().retentionSuppressedPaneKeys[TARGET_PANE_KEY]).toBeUndefined()
})
diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts
index 7462073d9f9..1d7ae9c5cd4 100644
--- a/src/renderer/src/store/slices/worktrees.ts
+++ b/src/renderer/src/store/slices/worktrees.ts
@@ -56,6 +56,7 @@ import { requestVirtualizedScrollAnchorRecord } from '@/hooks/requestVirtualized
import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output-activity'
import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs'
import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-delivery-guards'
+import { forgetAgentPaneAuthorityAliasesByTabIds } from './agent-pane-authority'
import { branchName } from '@/lib/git-utils'
import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler'
import { clearSessionCommitDraftForWorktree } from '@/lib/source-control-commit-draft-session'
@@ -2293,6 +2294,9 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial
Date: Wed, 29 Jul 2026 00:47:18 -0700
Subject: [PATCH 21/67] fix(terminal): bound SSH & remote hidden-worktree
terminal retention (C1) (#10625)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(terminal): park SSH worktrees like local ones (C1 retention, slice A)
SSH ptys were blanket-excluded from hidden-view parking, so a hidden SSH
worktree retained every pane forever (C1: renderer heap climbs to the V8
ceiling). SSH bytes transit local main — fact-mode watchers already cover
them, and main keeps a headless model served over pty:getMainBufferSnapshot
that the SSH reattach path never consulted.
- isParkRestorableTerminalPty: snapshot-backed OR (SSH + policy); threaded
through both park verdicts, both selectors, watcher coverage, and the
watcher start guard. Remote-runtime/fail-open/foreign/null unchanged.
- Parked-SSH reveal paints from main's headless model (dimension-matched,
~5k rows) and degrades to the relay 100KiB replay unless the snapshot is a
non-empty source==='headless' payload — never a blank/stale paint.
- Kill switch: settings.terminalSshViewParking (default on).
DESIGN.md records the approved plan and the H1 magnitude non-claim.
Co-authored-by: Orca
* fix(terminal): bound hidden-worktree retention with a force-park budget (C1, slice B)
Un-parkable worktrees (remote-runtime ptys, uncoverable tabs, SSH with the
slice-A switch off) had unlimited retention: the parking cap/TTL only ever
saw eligibility-passing worktrees, so one bad tab pinned a whole worktree's
panes forever. Retention is now memory-bounded, not eligibility-bounded.
- terminal-hidden-worktree-retention.ts: retention budget (12 hidden / 45min
TTL, sized from the measured 2.5-19MB per-pane V8 cost, DESIGN.md §2) over
hidden worktrees ordinary parking can never evict; reuses the hot-retain
ranking so last-active exemption, deterministic ties, and deadline-driven
rechecks hold. Fail-open/foreign-pty tabs are eviction-exempt (a remount
would fresh-spawn and orphan the live shell).
- Terminal.tsx: force-parked ids join the parked set AFTER the coverage veto
(darkness for uncoverable tabs is the accepted cost); buffers captured via
the sleep-flow registry before the unmount render; retention TTL added to
the recheck deadlines for budget candidates only.
- Verdict stays out of its own effect deps; policy test asserts idempotence
and time-monotone membership (flip-loop dwell regression).
- Kill switch: settings.terminalHiddenWorktreeRetentionBudget (default on).
Co-authored-by: Orca
* fix(terminal): demote hidden scrollback for eviction-exempt worktrees (C1, slice C)
The retention budget (slice B) must exempt worktrees holding fail-open or
foreign-worktree ptys — a remount would fresh-spawn and orphan the live
shell — which would leave that class unbounded again. Instead, past the same
45min retention TTL their hidden panes drop to the minimum scrollback tier
(measured: ~19MB -> ~1.3MB V8 heap per 50k-row pane; trimmed history is
gone by design, reveal restores the configured cap for future output).
- terminal-hidden-scrollback-demotion.ts: module-state verdict registry
(parked-watcher pattern) with content-equality notify damping; applied in
the existing scrollback-rows effect in use-terminal-pane-lifecycle.
- selectScrollbackDemotedTerminalWorktrees: pure, TTL-gated, time-monotone.
- Retention TTL wakeups now also cover exempt worktrees so demotion fires.
- Kill switch: settings.terminalHiddenScrollbackDemotion (default on).
Co-authored-by: Orca
* fix(terminal): paint the SSH model snapshot inline, not via nested coordinator (C1 slice A fix)
applyMainBufferSnapshot runs its own structuralReplayCoordinator.run; calling
it from applyReattachPayload (already inside the coordinator when a relay
replay exists) deadlocks on the coordinator's tail chain. The model paint now
mirrors the daemon-snapshot branch inline (folded scrollback + rehydrate +
screen, dimension-matched, escape tail last) and arms the restored-snapshot
seq baseline so deferred/live chunks the snapshot covers dedupe instead of
double-painting. Also falls through (no early return) so reattachPayloadApplied
still latches. Adds the folder-workspace id parity unit case.
Co-authored-by: Orca
* test(terminal): SSH park+reveal e2e round-trip + as-built design notes (C1)
Docker-gated (ORCA_E2E_SSH_DOCKER=1) spec: SSH tab parks behind a decoy and
reveal restores marker content at multi-viewport scrollback depth. DESIGN.md
records the as-built deltas (inline paint, force-park shape, last-active
floor) and the residuals so follow-ups aren't lost.
Co-authored-by: Orca
* fix(terminal): paint SSH reveal from main's model even when the relay replay is empty (C1 review #1)
A relay restart empties the replay buffer; the reveal previously painted
nothing even when main's headless model held the session. The reattach now
prefetches the model snapshot when no structural replay exists (SSH-shaped
ptys only) and paints it inside the coordinator; emptiness is judged on the
composed payload (scrollbackAnsi + data + pendingEscapeTailAnsi) so an
alt-screen snapshot with an empty screen frame still paints.
Co-authored-by: Orca
* fix(terminal): decouple scrollback demotion (slice C) from the retention-budget switch (C1 review #2)
Per the approved contract each slice reverts behind its own switch: slice C
now requires only the master terminalHiddenViewParking plus its own
terminalHiddenScrollbackDemotion flag. The TTL wakeup timer fires for
demotion candidates even with the budget switch off. No DEFAULT_SETTINGS
entries exist for sibling flags (defaults are the '!== false' optional
pattern), so no explicit defaults are added.
Co-authored-by: Orca
* fix(terminal): scope eviction exemption to the tab, not the worktree (C1 review #3)
One eviction-exempt tab (fail-open/foreign pty) previously vetoed force-park
for its whole worktree, pinning co-located remote-runtime tabs forever. The
worktree now force-parks while exempt tabs keep their mounted panes via a
per-tab exclusion mirroring the Activity-portal pattern (legacy watcher sync,
legacy render, and the overlay cold-parking hook). Ordinary parking is
untouched — a worktree with an exempt tab still cannot ordinary-park.
Slice C now also demotes exempt tabs' panes as soon as their worktree
force-parks under the count budget (they are the only panes left mounted).
Co-authored-by: Orca
* fix(terminal): demote un-parkable worktrees the force-park lever spared (C1 review #4)
The last-active exemption means a single hidden un-parkable worktree never
force-parks — and slice C previously only targeted exempt-tab worktrees, so
its panes held full scrollback forever. Demotion now also covers un-parkable
non-exempt worktrees past the retention TTL that are absent from the
force-parked set (last-active spared, or slice B switched off). Membership
stays time-monotone for fixed inputs; covered by new idempotence/monotone
selector tests.
Co-authored-by: Orca
* fix(terminal): keep the hidden clock running through transient background-measure windows (C1 review #5)
Whole-worktree background mounts (browser-automation bootstrap lease, mobile
mounts, agent wakes) open a ~3s self-clearing measure window that previously
deleted hiddenSince — every remount restarted the 30s hysteresis and the
45min retention TTL, so a periodically re-mounted force-parked worktree
never re-parked. The measure window still pauses parking/eviction verdicts
(all selectors skip measuring candidates); only the clock survives, so the
prior verdict resumes as soon as the window closes. Visible and
portal-holding worktrees still reset the clock.
Co-authored-by: Orca
* test(terminal): make the SSH park+reveal depth assertion prove the model paint (C1 review #6a)
Pad the session with ~180KB of output after the numbered markers so the
earliest marker falls outside the relay's 100KiB rolling replay buffer while
staying inside main's ~5k-row headless model; asserting marker_1 after
reveal now proves the headless-model paint rather than passing under the
relay fallback.
Co-authored-by: Orca
* docs(terminal): rewrite DESIGN.md as the single as-built C1 contract (review #7)
One contract matching the code: status IMPLEMENTED around force-park (not
the unmount proposal), real kill-switch names with coupling + revert
matrices, the true retention-floor formula with measured per-pane and
demotion numbers, an explicit when-OOM-is-still-possible paragraph naming
the H2 pendingSideEffects residual, the applyMainBufferSnapshot deadlock
constraint inside the slice-A section, stable-signal phrasing instead of a
capability latch, fail-open AND foreign-worktree exemption class, verified
cites, and a planned/landed/follow-up test matrix.
Co-authored-by: Orca
* fix(terminal): resolve the eviction exemption per pane, not per tab (C1 review #8)
isEvictionExemptTerminalTab read only tab.ptyId — the FIRST leaf's pty —
while the coverage veto that makes a worktree a retention candidate walks
every pane. A split tab whose second leaf held an unrestorable pty therefore
failed coverage (→ force-park target) yet looked exempt-free, so force-park
unmounted it and orphaned the live shell. The exemption now resolves panes
through the same resolveParkedTerminalPaneCandidates, keeping tab.ptyId in
the union for the no-layout/no-capture case.
Also from the same review round:
- force-park's capture passes includeLocalBuffers:false like every other
shutdownBufferCaptures caller; it was serializing up to 512KB/pane of
scrollback into the store inside a fix meant to bound renderer heap.
- Terminal.tsx unmount resets the scrollback-demotion registry — module
state with no reset path, read by a pane effect that runs before the host
effect that would clear it, so a stale verdict trimmed restore replays.
- memoize watcher coverage per tab within the parking pass; the retention
candidates re-asked it for every mounted worktree, not just the parked few.
* docs(terminal): drop DESIGN.md — the as-built C1 contract moves to the PR body
Co-authored-by: Orca
* fix(terminal): cap the deferred PTY side-effect queue (C1 residual H2)
pendingSideEffects grew without bound under background timer throttling
(~64 drained/s vs hundreds queued/s overnight). Cap at 512 entries with
oldest-first eviction: titles drop (last-wins), a pending bell latches
onto the next survivor, agent-status payloads collapse onto the survivor
keeping the newest 16 (last-wins store state, KB-scale strings).
Co-authored-by: Orca
* fix(terminal): carry command-lifecycle facts through parked watchers (C1 follow-up)
Parked fact-mode watchers omitted onCommandFinished/onCommandCode*, so
OSC 133;D and Command Code scrape signals went dark while parked. New
parked-terminal-command-status.ts ports the store-level subset: git-UI
nudge on every command finish, same-turn status-row drop for SSH PTYs
(exact mounted-path parity — the foreground tracker refuses SSH ids),
and the Command Code working seed / 1500ms done settle. Byte mode scans
the same shared parsers for authority-off parity. Local-PTY status drops
stay with the mounted pane: they need pty-connection's process-confirm
ladder to tell a leaked nested-shell 133;D from a real agent exit.
Co-authored-by: Orca
* test(terminal): retention-budget force-park e2e with a retentionLimit override (C1 6b)
ORCA_E2E_TERMINAL_RETENTION_LIMIT flows preload → e2e-config →
getTerminalParkingPolicyOverrides (exposeStore-gated, positive-integer
only) so a spec can shrink the force-park budget to 1. The Docker-gated
spec opens two remote worktrees on one relay target (second pre-seeded
remote repo), disables terminalSshViewParking to make both un-parkable,
hides both behind the local context, and proves the older one force-parks
while the last-active exemption spares the newest; re-activating the
evicted worktree restores the marker tail via relay replay.
Co-authored-by: Orca
* test(terminal): retention-budget e2e via same-repo remote worktrees (passes docker lane)
The first draft added a second remote repo mid-session, whose pane pty
spawn misroutes to the local daemon with the remote cwd (pre-existing
multi-repo issue, reproducible without any retention override — a seeded
local repo plus one remote repo shows the same misroute). The spec now
budgets across three worktrees of the ONE connected repo, created through
the product createWorktree path (an external git-worktree-add only lands
as a detected worktree needing adoption) and polled through the relay's
transient post-connect reconnect window. Verified green on the local
Docker lane in 20.8s.
Co-authored-by: Orca
* fix(terminal): prevent remount thrashing during post-measure cool-down (
Implements the C1 retention contract: preserve worktree `hiddenSinceMs` through a
background-measure window (so TTL/ranking stay honest), but re-park waits for a
full `coldParkDelayMs` cool-down after the measure ends. Without the cool-down,
every ~3s measure lease on a past-deadline worktree thrashes remount/reattach.
Core changes:
- Terminal.tsx: add measure clock (measuringTerminalWorktreeIdsRef) and post-measure
cool-down tracking (terminalWorktreeParkCooldownUntilRef); gate parking candidates
until cool-down expires.
- Extract snapshot replay choreography to shared terminal-snapshot-replay-paint.ts
(used by SSH reattach + daemon restore paths).
- Add SSH model snapshot timeout (750ms) with fallback to relay replay.
- Move cold-park recheck deadline logic to terminal-cold-park-recheck-deadlines.ts;
add cool-down deadline to scheduling.
- useTerminalTabColdParking: implement matching measure-clock contract with per-tab
cool-down gate to keep tab deadlines synced with worktree retention clock.
- Add resolveTerminalMountScrollbackRows() to demote new xterms under demoted
worktrees (pane births during demotion must take the demoted tier at create).
- Add kill switches: terminalSshViewParking, terminalHiddenWorktreeRetentionBudget,
terminalHiddenScrollbackDemotion.
* fix(terminal): detect Command Code completion in parked mid-turn panes
Seed the byte watcher with in-flight turn state from agent status: the
watcher is recreated per park cycle with no startup command to arm it,
and the banner scrolled away before parking. Also memoize
eviction-exempt checks and use SSH PTY ID builder in tests.
* fix(terminal): flush pending command-code settles on reveal remount
When a parked pane reveals mid-Command Code turn, the new detector
cannot re-observe the already-passed idle composer. Cancelling the settle
leaves the row stranded at 'working', so dispose now flushes the pending
settle instead.
Extract readInFlightCommandCodeTurn to shared space and seed detectors
with in-flight turns so remounts complete mid-flight commands. Also
memoize SSH model probes to prevent double timeouts on reattach.
* fix(terminal): remove scrollback demotion (C1 slice C)
The scrollback demotion feature for eviction-exempt hidden worktrees is no longer needed. Retention budget limits are now sufficient without this additional bound. Remove the terminal-hidden-scrollback-demotion module, the selectScrollbackDemotedTerminalWorktrees function, and related per-pane demotion logic.
* test(terminal): assert bounded probe during stalled reveal
Add assertion to verify that a stalled reveal operation makes exactly one
`getMainBufferSnapshot` call, ensuring retry logic doesn't introduce
redundant probes that would extend the timeout window before relay fallback.
* fix(terminal): implement C1 retention budget for hidden parked worktrees
Addresses OOM regressions in hidden parked terminals by force-evicting
worktrees past a retention budget: at most 12 mounted while hidden, none
past 45 minutes (absolute, not exempted by last-active). Eviction is
least-recently-hidden-first. Exempt tabs (unrestorable local PTYs) keep
their panes to avoid orphaning shells; worktrees are force-parked even
if they contain exempts, and their buffers released elsewhere. SSH/remote
worktrees serialize buffers pre-eviction for reveal; local worktrees keep
daemon snapshots. Command Code's done-settle window is transferred across
park/reveal boundaries so the row cannot strand at 'working'. Model probe
on SSH reattach is scoped to park-reveal only, not ordinary reconnects.
Includes new E2E suite proving the budget actually releases memory.
* memoize eviction-exempt terminal tabs to avoid redundant store reads
Each tab's exemption check re-reads the store and walks the layout tree.
Introduce selectEvictionExemptTerminalTabIds() to resolve all exempt tabs
for a worktree in a single pass, then memoize the result in Terminal.tsx
and useTerminalTabColdParking. This prevents O(n) store reads when checking
exemptions across multiple tabs and ensures the set remains stable across
unrelated re-renders.
* refactor: reformat hidden-worktree retention comments
Reflow to 80-character lines and remove internal ticket references
(C1, C1 slice C).
* fix(lint): split overlay slot and eviction-exempt tabs under max-lines
Static analysis failed because TerminalPaneOverlayLayer (401) and
terminal-parked-tab-watchers (304) exceeded oxlint max-lines. Extract the
slot component and eviction-exempt helpers into dedicated modules.
* test(terminal): stabilize retention budget e2e control arm
Stage un-parkable remote pty ids only after both worktrees are hidden, and
keep re-staging during the control-arm poll so a late updateTabPtyId cannot
flip the decoy back to park-restorable and ordinary-park it before budget
engages.
* test(terminal): pin retention e2e decoy to a mounted pane snapshot
Use the active pane-identity snapshot for the decoy tab instead of all
worktree tabs, and re-assert un-parkable ids after the control-arm hold so
a deferred/empty tab id cannot fail the budget-off mounted-count check.
* fix: memoize terminal eviction exemptions on layout leaf PTYs
Splits add leaf panes to the layout store without changing the tabs
array. A memo keyed only on tabs misses this change, leaving new panes
unexempted for unmount. Include layout leaf PTYs in the exemption memo
key so it recalculates when splits occur or PTYs are re-minted.
---------
Co-authored-by: Orca
---
.github/workflows/e2e.yml | 11 +-
.../run-ssh-docker-terminal-parking-e2e.mjs | 45 ++
package.json | 1 +
src/main/ipc/pty.test.ts | 39 ++
src/main/ipc/pty.ts | 5 +
src/preload/e2e-config.ts | 3 +-
src/renderer/src/components/Terminal.tsx | 247 ++++++++++-
.../terminal-pane/TerminalOverlaySlot.tsx | 290 ++++++++++++
...TerminalPaneOverlayLayer.react185.test.tsx | 2 +-
.../TerminalPaneOverlayLayer.tsx | 294 +------------
.../command-code-done-settle.test.ts | 113 +++++
.../terminal-pane/command-code-done-settle.ts | 64 +++
.../force-park-buffer-capture.test.ts | 61 +++
.../force-park-buffer-capture.ts | 29 ++
.../parked-terminal-byte-watcher.test.ts | 108 ++++-
.../parked-terminal-byte-watcher.ts | 38 ++
.../parked-terminal-command-status.test.ts | 345 +++++++++++++++
.../parked-terminal-command-status.ts | 197 +++++++++
.../terminal-pane/pty-connection.test.ts | 95 ++++
.../terminal-pane/pty-connection.ts | 266 +++++++----
.../terminal-pane/pty-transport.test.ts | 98 +++++
.../components/terminal-pane/pty-transport.ts | 40 ++
.../replayed-scrollback-store-release.test.ts | 63 +++
.../replayed-scrollback-store-release.ts | 22 +
.../shutdown-buffer-captures.test.ts | 39 ++
.../terminal-pane/shutdown-buffer-captures.ts | 22 +
.../ssh-reattach-model-restore.test.ts | 157 +++++++
.../ssh-reattach-model-restore.ts | 85 ++++
...rminal-cold-park-recheck-deadlines.test.ts | 172 ++++++++
.../terminal-cold-park-recheck-deadlines.ts | 72 +++
.../terminal-eviction-exempt-tabs.test.ts | 49 +++
.../terminal-eviction-exempt-tabs.ts | 87 ++++
.../terminal-hidden-view-parking.test.ts | 204 ++++-----
.../terminal-hidden-view-parking.ts | 114 +++--
...terminal-hidden-worktree-retention.test.ts | 195 +++++++++
.../terminal-hidden-worktree-retention.ts | 130 ++++++
.../terminal-parked-tab-watchers.test.ts | 124 +++++-
.../terminal-parked-tab-watchers.ts | 27 +-
.../terminal-parked-watcher-registry.ts | 10 +
.../terminal-parking-e2e-overrides.test.ts | 68 ++-
.../terminal-parking-e2e-overrides.ts | 21 +-
.../terminal-shutdown-layout-capture.test.ts | 72 +++
.../terminal-shutdown-layout-capture.ts | 62 ++-
...terminal-side-effect-facts-handler.test.ts | 130 ++++++
.../terminal-side-effect-facts-handler.ts | 77 +++-
.../terminal-snapshot-replay-paint.test.ts | 68 +++
.../terminal-snapshot-replay-paint.ts | 62 +++
.../use-terminal-pane-lifecycle.ts | 18 +-
.../use-terminal-tab-cold-parking.test.ts | 214 +++++++++
.../use-terminal-tab-cold-parking.ts | 81 +++-
src/shared/command-code-output-status.ts | 9 +-
src/shared/constants.ts | 4 +
src/shared/e2e-config.ts | 14 +-
src/shared/types.ts | 4 +
.../docker-ssh-relay-worktree-activation.ts | 67 +++
tests/e2e/helpers/terminal-hidden-parking.ts | 13 +-
tests/e2e/ssh-terminal-parking.spec.ts | 115 +++++
tests/e2e/terminal-parked-memory.spec.ts | 413 +++++++++++++++++-
tests/e2e/terminal-retention-budget.spec.ts | 142 ++++++
59 files changed, 5003 insertions(+), 614 deletions(-)
create mode 100644 config/scripts/run-ssh-docker-terminal-parking-e2e.mjs
create mode 100644 src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx
create mode 100644 src/renderer/src/components/terminal-pane/command-code-done-settle.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/command-code-done-settle.ts
create mode 100644 src/renderer/src/components/terminal-pane/force-park-buffer-capture.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/force-park-buffer-capture.ts
create mode 100644 src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts
create mode 100644 src/renderer/src/components/terminal-pane/replayed-scrollback-store-release.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/replayed-scrollback-store-release.ts
create mode 100644 src/renderer/src/components/terminal-pane/shutdown-buffer-captures.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts
create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts
create mode 100644 tests/e2e/helpers/docker-ssh-relay-worktree-activation.ts
create mode 100644 tests/e2e/ssh-terminal-parking.spec.ts
create mode 100644 tests/e2e/terminal-retention-budget.spec.ts
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index c2b597fafbf..2ab1ed44421 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -184,7 +184,9 @@ jobs:
name: ssh docker watcher isolation
needs: build
runs-on: ubuntu-latest
- timeout-minutes: 20
+ # Why 35: the terminal parking + retention specs below add two more
+ # docker-rig tests capped at 240s each on top of the watcher isolation pair.
+ timeout-minutes: 35
steps:
- name: Checkout
@@ -227,6 +229,13 @@ jobs:
- name: Run Docker SSH watcher isolation E2E
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation
+ # Why always(): these specs gate the C1 terminal parking/retention budget
+ # over a real SSH host and run in no other lane, so a watcher-isolation
+ # failure above must not silently skip them.
+ - name: Run Docker SSH terminal parking + retention E2E
+ if: always()
+ run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
+
- name: Upload watcher isolation traces
if: failure()
uses: actions/upload-artifact@v7
diff --git a/config/scripts/run-ssh-docker-terminal-parking-e2e.mjs b/config/scripts/run-ssh-docker-terminal-parking-e2e.mjs
new file mode 100644
index 00000000000..8d4183b4515
--- /dev/null
+++ b/config/scripts/run-ssh-docker-terminal-parking-e2e.mjs
@@ -0,0 +1,45 @@
+import { spawnSync } from 'node:child_process'
+
+const rawExtraArgs = process.argv.slice(2)
+const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
+const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
+const env = {
+ ...process.env,
+ ORCA_E2E_SSH_DOCKER: '1'
+}
+
+// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
+const spawnOptions = {
+ stdio: 'inherit',
+ env,
+ shell: process.platform === 'win32'
+}
+
+const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
+
+if (runtime.status !== 0) {
+ process.exit(runtime.status ?? 1)
+}
+
+// Why both specs in one runner: they share the docker SSH rig and the same
+// ORCA_E2E_SSH_DOCKER gate — the SSH parking spec proves SSH panes park and
+// restore, the retention spec proves the C1 budget bounds what they retain.
+const result = spawnSync(
+ pnpm,
+ [
+ 'exec',
+ 'playwright',
+ 'test',
+ 'tests/e2e/ssh-terminal-parking.spec.ts',
+ 'tests/e2e/terminal-retention-budget.spec.ts',
+ '--config',
+ 'tests/playwright.config.ts',
+ '--project',
+ 'electron-headless',
+ '--workers=1',
+ ...extraArgs
+ ],
+ spawnOptions
+)
+
+process.exit(result.status ?? 1)
diff --git a/package.json b/package.json
index cdb8880f8b3..df94846cc3f 100644
--- a/package.json
+++ b/package.json
@@ -98,6 +98,7 @@
"test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs",
"test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs",
"test:e2e:ssh-docker-watcher-isolation": "node config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs",
+ "test:e2e:ssh-docker-terminal-parking": "node config/scripts/run-ssh-docker-terminal-parking-e2e.mjs",
"test:e2e:nested-runtime-ssh": "node config/scripts/run-nested-runtime-ssh-e2e.mjs",
"test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"win-update-e2e": "node tools/win-update-e2e/run.mjs",
diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts
index 58b841508bd..190a1288be4 100644
--- a/src/main/ipc/pty.test.ts
+++ b/src/main/ipc/pty.test.ts
@@ -14461,6 +14461,45 @@ describe('registerPtyHandlers', () => {
)
})
+ it('seeds the headless emulator from an SSH relay reattach replay', async () => {
+ setLocalPtyProvider({
+ spawn: vi.fn(async () => ({
+ id: 'pty-ssh-reattach',
+ isReattach: true,
+ replay: 'relay history\r\n'
+ })),
+ write: vi.fn(),
+ resize: vi.fn(),
+ kill: vi.fn(),
+ shutdown: vi.fn(),
+ onData: vi.fn(() => vi.fn()),
+ onExit: vi.fn(() => vi.fn()),
+ listProcesses: vi.fn(async () => []),
+ getForegroundProcess: vi.fn(async () => null)
+ } as never)
+ const runtime = {
+ setPtyController: vi.fn(),
+ noteTerminalSpawnCommand: vi.fn(),
+ seedHeadlessTerminal: vi.fn(),
+ onPtySpawned: vi.fn(),
+ onPtyData: vi.fn(),
+ onPtyExit: vi.fn(),
+ createPreAllocatedTerminalHandle: vi.fn(() => 'handle-ssh-reattach'),
+ registerPreAllocatedHandleForPty: vi.fn(),
+ preAllocateHandleForPty: vi.fn()
+ }
+ registerPtyHandlers(mainWindow as never, runtime as never)
+
+ await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
+
+ // Why: without this seed main's model would be a strict suffix of what the renderer painted, and a later park-reveal would restore the fragment.
+ expect(runtime.seedHeadlessTerminal).toHaveBeenCalledTimes(1)
+ expect(runtime.seedHeadlessTerminal).toHaveBeenCalledWith(
+ 'pty-ssh-reattach',
+ 'relay history\r\n'
+ )
+ })
+
it('upgrades legacy numeric pane keys when the spawn metadata proves the stable leaf', async () => {
registerPtyHandlers(mainWindow as never)
const leafId = '11111111-1111-4111-8111-111111111111'
diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts
index feae29a0096..5a82ff3dfa0 100644
--- a/src/main/ipc/pty.ts
+++ b/src/main/ipc/pty.ts
@@ -5069,6 +5069,11 @@ export function registerPtyHandlers(
preferProviderIfExisting: true
}
)
+ } else if (typeof result.replay === 'string' && result.replay.length > 0) {
+ // Why: the relay reattach replay is the one restore main never ingests, so without
+ // this seed its model is a mere suffix of what the renderer painted — and a later
+ // park-reveal would outrank the fuller relay replay with that fragment.
+ runtime.seedHeadlessTerminal(result.id, result.replay)
}
}
if (
diff --git a/src/preload/e2e-config.ts b/src/preload/e2e-config.ts
index f1cdf668ce3..8414f62d39a 100644
--- a/src/preload/e2e-config.ts
+++ b/src/preload/e2e-config.ts
@@ -23,5 +23,6 @@ export const preloadE2EConfig = createE2EConfig({
userDataDir: process.env.ORCA_E2E_USER_DATA_DIR ?? null,
// Why: Number('') is 0 and Number(undefined) is NaN; both coerce to null so
// only a real positive override reaches the renderer parking policy.
- terminalParkingDelayMs: Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || null
+ terminalParkingDelayMs: Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || null,
+ terminalRetentionLimit: Number(process.env.ORCA_E2E_TERMINAL_RETENTION_LIMIT) || null
})
diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx
index 96469e69fec..c87c25cbd4c 100644
--- a/src/renderer/src/components/Terminal.tsx
+++ b/src/renderer/src/components/Terminal.tsx
@@ -36,7 +36,13 @@ import {
import { isIntentionalAppRestartInProgress } from '@/lib/updater-beforeunload'
import { preventUnloadAndScheduleShutdownCheckpointReset } from '@/lib/shutdown-checkpoint-guard'
import EditorAutosaveController from './editor/EditorAutosaveController'
-import type { Tab, TabContentType, TabGroupLayoutNode, TuiAgent } from '../../../shared/types'
+import type {
+ Tab,
+ TabContentType,
+ TabGroupLayoutNode,
+ TerminalTab,
+ TuiAgent
+} from '../../../shared/types'
import { hasFeatureInteraction } from '../../../shared/feature-interactions'
import BrowserPane from './browser-pane/BrowserPane'
import BrowserPaneOverlayLayer from './browser-pane/BrowserPaneOverlayLayer'
@@ -78,13 +84,24 @@ import {
import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { setForegroundTerminalTabIds } from '@/lib/foreground-terminal-tabs'
+import { getTerminalWorktreeColdParkRecheckDelayMs } from './terminal-pane/terminal-cold-park-recheck-deadlines'
import {
- getTerminalWorktreeColdParkRecheckDelayMs,
+ TERMINAL_WORKTREE_COLD_PARK_DELAY_MS,
+ canParkTerminalWorktreeRenderers,
selectColdParkedTerminalWorktrees,
type TerminalWorktreeColdParkCandidate
} from './terminal-pane/terminal-hidden-view-parking'
+import {
+ TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS,
+ selectForceParkEvictableTabIds,
+ selectRetentionForceParkedTerminalWorktrees,
+ type TerminalWorktreeRetentionCandidate
+} from './terminal-pane/terminal-hidden-worktree-retention'
+import { captureForceParkedWorktreeBuffers } from './terminal-pane/force-park-buffer-capture'
+import { warnTerminalLifecycleAnomaly } from './terminal-pane/terminal-lifecycle-diagnostics'
import { getTerminalParkingPolicyOverrides } from './terminal-pane/terminal-parking-e2e-overrides'
import { useColdParkedTerminalPresentation } from './terminal-pane/use-cold-parked-terminal-presentation'
+import { selectEvictionExemptTerminalTabIds } from './terminal-pane/terminal-eviction-exempt-tabs'
import {
canWatcherCoverParkedTerminalTab,
disposeAllParkedTerminalWatchers,
@@ -156,7 +173,7 @@ const EDITOR_TAB_CONTENT_TYPES = new Set([
type TerminalStoreSnapshot = ReturnType
-function haveSameWorktreeIds(left: ReadonlySet, right: ReadonlySet): boolean {
+function haveSameIdSet(left: ReadonlySet, right: ReadonlySet): boolean {
if (left.size !== right.size) {
return false
}
@@ -251,6 +268,12 @@ function Terminal(): React.JSX.Element | null {
const mountedWorktreeIdsRef = useRef(new Set())
const measurableBackgroundWorktreeIdsRef = useRef(new Set())
const terminalWorktreeHiddenSinceRef = useRef(new Map())
+ // Why two extra clocks: hiddenSince survives a background-measure window (so
+ // TTL/ranking stay honest), but re-park must wait a full coldParkDelayMs
+ // after the measure ends — otherwise every ~3s measure lease on a
+ // past-deadline worktree thrashes remount/reattach with an immediate re-park.
+ const measuringTerminalWorktreeIdsRef = useRef(new Set())
+ const terminalWorktreeParkCooldownUntilRef = useRef(new Map())
const terminalWorktreeParkingTimersRef = useRef(new Map())
const allWorktrees = useAllWorktrees()
const folderWorkspaces = useAppStore((s) => s.folderWorkspaces)
@@ -273,6 +296,10 @@ function Terminal(): React.JSX.Element | null {
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId)
const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false)
+ const terminalSshParkingEnabled = useAppStore((s) => s.settings?.terminalSshViewParking !== false)
+ const terminalRetentionBudgetEnabled = useAppStore(
+ (s) => s.settings?.terminalHiddenWorktreeRetentionBudget !== false
+ )
const terminalTitleSnapshotAuthorityEnabled = useAppStore((s) =>
isMainTerminalSideEffectAuthorityForPty({
settings: s.settings,
@@ -752,6 +779,21 @@ function Terminal(): React.JSX.Element | null {
combineTerminalWorktreeParkIds(parkedTerminalWorktreeIds, manuallyParkedTerminalWorktreeIds),
[manuallyParkedTerminalWorktreeIds, parkedTerminalWorktreeIds]
)
+ // Why separate from parkedTerminalWorktreeIds: force-parked worktrees keep
+ // their eviction-exempt tabs' panes mounted (per-tab exclusion), which
+ // ordinary parks never need — render and watcher sync must tell them apart.
+ const [forceParkedTerminalWorktreeIds, setForceParkedTerminalWorktreeIds] = useState<
+ ReadonlySet
+ >(() => new Set())
+ // Why state, resolved in the parking pass: each exemption re-reads the store
+ // and walks the layout/capture candidates, so the render gate and watcher
+ // sync consume one set per force-park verdict instead of re-asking per tab on
+ // every unrelated Terminal render.
+ const [evictionExemptTerminalTabIds, setEvictionExemptTerminalTabIds] = useState<
+ ReadonlySet
+ >(() => new Set())
+ // Why a ref: eviction captures buffers exactly once per force-park episode, before the unmount render.
+ const forceParkedCaptureDoneRef = useRef(new Set())
// Tab restriction for targeted background mounts (wake/resume); a worktree absent from this map mounts all its tabs.
const backgroundMountTabIdsByWorktreeRef = useRef(new Map>())
// Why: only cold-activation deferral (not targeted mounts, which share the map above) creates watcher coverage for every unmounted tab.
@@ -849,6 +891,8 @@ function Terminal(): React.JSX.Element | null {
for (const worktreeId of Array.from(terminalWorktreeHiddenSinceRef.current.keys())) {
if (!currentWorktreeIds.has(worktreeId) || !mountedWorktreeIdsRef.current.has(worktreeId)) {
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
+ measuringTerminalWorktreeIdsRef.current.delete(worktreeId)
+ terminalWorktreeParkCooldownUntilRef.current.delete(worktreeId)
}
}
@@ -857,16 +901,42 @@ function Terminal(): React.JSX.Element | null {
const worktreeId = workspace.id
if (!mountedWorktreeIdsRef.current.has(worktreeId)) {
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
+ measuringTerminalWorktreeIdsRef.current.delete(worktreeId)
+ terminalWorktreeParkCooldownUntilRef.current.delete(worktreeId)
continue
}
const isVisible = activeView === 'terminal' && renderedActiveWorktreeId === worktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktreeId)
const hasActivityTerminalPortal = portalWorktreeIds.has(worktreeId)
- if (isVisible || shouldMeasureHiddenWorktree || hasActivityTerminalPortal) {
+ // Why the cool-down: hiddenSince deliberately survives the measure (see
+ // below), so a past-deadline worktree would otherwise re-park in the
+ // same pass the measure lease ends — remount/reattach thrash on every
+ // periodic probe. The cool-down re-grants the coldParkDelayMs hysteresis
+ // without touching the TTL/ranking clock.
+ if (shouldMeasureHiddenWorktree) {
+ measuringTerminalWorktreeIdsRef.current.add(worktreeId)
+ } else {
+ if (measuringTerminalWorktreeIdsRef.current.has(worktreeId)) {
+ terminalWorktreeParkCooldownUntilRef.current.set(
+ worktreeId,
+ nowMs + (overrides.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS)
+ )
+ }
+ measuringTerminalWorktreeIdsRef.current.delete(worktreeId)
+ }
+ if (isVisible || hasActivityTerminalPortal) {
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
- } else if (!terminalWorktreeHiddenSinceRef.current.has(worktreeId)) {
- terminalWorktreeHiddenSinceRef.current.set(worktreeId, nowMs)
+ terminalWorktreeParkCooldownUntilRef.current.delete(worktreeId)
+ } else if (!shouldMeasureHiddenWorktree) {
+ // Why measuring is excluded here but still pauses the verdicts below:
+ // the ~3s background-measure window (automation lease, mobile mount,
+ // agent wake) is transient — resetting hiddenSince would restart the
+ // 30s hysteresis AND the 45min retention TTL on every remount, so a
+ // periodically re-mounted force-parked worktree would never re-park.
+ if (!terminalWorktreeHiddenSinceRef.current.has(worktreeId)) {
+ terminalWorktreeHiddenSinceRef.current.set(worktreeId, nowMs)
+ }
}
retentionCandidates.push({
@@ -875,29 +945,151 @@ function Terminal(): React.JSX.Element | null {
isVisible,
shouldMeasureHiddenWorktree,
hasActivityTerminalPortal,
- hiddenSinceMs: terminalWorktreeHiddenSinceRef.current.get(worktreeId) ?? null
+ hiddenSinceMs: terminalWorktreeHiddenSinceRef.current.get(worktreeId) ?? null,
+ parkCooldownUntilMs: terminalWorktreeParkCooldownUntilRef.current.get(worktreeId) ?? null
})
}
+ const restorePolicy = { sshParkingEnabled: terminalSshParkingEnabled }
const nextParkedTerminalWorktreeIds = selectColdParkedTerminalWorktrees({
worktrees: retentionCandidates,
pendingStartupByTabId,
parkingEnabled: terminalParkingEnabled,
nowMs,
+ restorePolicy,
...overrides
})
+ // Why memoized: the retention pass below re-asks coverage for EVERY mounted
+ // worktree's tabs, not just the parked few — each call re-reads the store and
+ // can walk the layout tree, so cache per tab for this pass.
+ const watcherCoverageByTabId = new Map()
+ const worktreeTabsAreWatcherCovered = (worktreeId: string, tabs: TerminalTab[]): boolean =>
+ tabs.every((tab) => {
+ const cached = watcherCoverageByTabId.get(tab.id)
+ if (cached !== undefined) {
+ return cached
+ }
+ const covered = canWatcherCoverParkedTerminalTab(worktreeId, tab)
+ watcherCoverageByTabId.set(tab.id, covered)
+ return covered
+ })
// Why: a worktree with any watcher-uncoverable tab must never park, or it goes silent for bells/titles/completions (sank the first parking attempt).
for (const worktreeId of Array.from(nextParkedTerminalWorktreeIds)) {
- const tabs = tabsByWorktree[worktreeId] ?? []
- if (!tabs.every((tab) => canWatcherCoverParkedTerminalTab(worktreeId, tab))) {
+ if (!worktreeTabsAreWatcherCovered(worktreeId, tabsByWorktree[worktreeId] ?? [])) {
nextParkedTerminalWorktreeIds.delete(worktreeId)
}
}
+ // C1 retention budget: worktrees ordinary parking can never evict (SSH off,
+ // remote-runtime, uncoverable tabs) force-park beyond a count/TTL bound —
+ // added AFTER the coverage veto because darkness for their uncoverable tabs
+ // is the accepted cost of bounding retention.
+ const retentionBudgetCandidates: TerminalWorktreeRetentionCandidate[] = retentionCandidates.map(
+ (candidate) => {
+ const tabs = tabsByWorktree[candidate.worktreeId] ?? []
+ const parkEligible = canParkTerminalWorktreeRenderers({
+ ...candidate,
+ // Why nulled: the post-measure cool-down is a timing gate, not a
+ // coverage change — ordinary parking still covers this worktree, it
+ // just may not re-engage yet. Leaving it set would misclassify the
+ // worktree as a retention candidate for the cool-down window.
+ parkCooldownUntilMs: null,
+ pendingStartupByTabId,
+ parkingEnabled: terminalParkingEnabled,
+ nowMs,
+ restorePolicy,
+ ...(overrides.coldParkDelayMs !== undefined
+ ? { coldParkDelayMs: overrides.coldParkDelayMs }
+ : {})
+ })
+ return {
+ worktreeId: candidate.worktreeId,
+ hiddenSinceMs: candidate.hiddenSinceMs,
+ isVisible: candidate.isVisible,
+ shouldMeasureHiddenWorktree: candidate.shouldMeasureHiddenWorktree,
+ hasActivityTerminalPortal: candidate.hasActivityTerminalPortal,
+ parkCooldownUntilMs: candidate.parkCooldownUntilMs,
+ ordinaryParkingCovers:
+ parkEligible && worktreeTabsAreWatcherCovered(candidate.worktreeId, tabs),
+ hasPendingSpawnWork: tabs.some(
+ (tab) =>
+ pendingStartupByTabId[tab.id] !== undefined ||
+ tab.pendingActivationSpawn === true ||
+ (typeof tab.pendingActivationSpawn === 'number' && tab.pendingActivationSpawn > 0)
+ )
+ }
+ }
+ )
+ const forceParkedWorktreeIds = selectRetentionForceParkedTerminalWorktrees({
+ worktrees: retentionBudgetCandidates,
+ parkingEnabled: terminalParkingEnabled,
+ retentionBudgetEnabled: terminalRetentionBudgetEnabled,
+ nowMs,
+ ...overrides
+ })
+ const capturedForceParked = forceParkedCaptureDoneRef.current
+ for (const id of Array.from(capturedForceParked)) {
+ if (!forceParkedWorktreeIds.has(id)) {
+ capturedForceParked.delete(id)
+ }
+ }
+ // Why getState and not a dep: this read happens in the same pass that
+ // unmounts the panes, so it is the current catalog; re-running on a later
+ // repos change could not re-capture buffers whose panes are already gone.
+ const repos = useAppStore.getState().repos
+ const nextEvictionExemptTabIds = new Set()
+ for (const worktreeId of forceParkedWorktreeIds) {
+ const forceParkedTabs = tabsByWorktree[worktreeId] ?? []
+ const exemptTabIds = selectEvictionExemptTerminalTabIds(worktreeId, forceParkedTabs)
+ for (const tabId of exemptTabIds) {
+ nextEvictionExemptTabIds.add(tabId)
+ }
+ if (!capturedForceParked.has(worktreeId)) {
+ // Why: serialize buffers while the panes still exist (same registry sleep
+ // uses), so SSH classes whose reveal has no local snapshot still show
+ // last-known content. includeLocalBuffers:false is required here, not
+ // optional — a heap fix must not plant 512KB/pane of scrollback strings
+ // in the store for local worktrees that already have the daemon
+ // snapshot; a remote-runtime pane in a local repo likewise needs none,
+ // its reveal repaints from the runtime's own subscribe snapshot.
+ // Eviction-exempt tabs never unmount (per-tab exclusion), so they need
+ // no pre-unmount capture — skipping spares a serialize+setTabLayout
+ // walk on live panes per force-park episode.
+ const evictableTabIds = selectForceParkEvictableTabIds(forceParkedTabs, (tab) =>
+ exemptTabIds.has(tab.id)
+ )
+ // Why logged: an all-exempt force-park frees no heap at all (daemon
+ // fail-open makes every local pty exempt), so the budget only holds
+ // while this stays rare — make the degenerate case observable.
+ if (evictableTabIds.length === 0 && forceParkedTabs.length > 0) {
+ warnTerminalLifecycleAnomaly('retention force-park freed no panes', {
+ worktreeId,
+ reason: `exemptTabs=${forceParkedTabs.length}`
+ })
+ }
+ // Why conditional: a tab whose pane was mid-remount registered no
+ // capture callback, and a later episode is this pass's only retry.
+ if (captureForceParkedWorktreeBuffers({ worktreeId, tabIds: evictableTabIds, repos })) {
+ capturedForceParked.add(worktreeId)
+ }
+ }
+ nextParkedTerminalWorktreeIds.add(worktreeId)
+ }
setParkedTerminalWorktreeIds((current) =>
- haveSameWorktreeIds(current, nextParkedTerminalWorktreeIds)
+ haveSameIdSet(current, nextParkedTerminalWorktreeIds)
? current
: nextParkedTerminalWorktreeIds
)
+ setForceParkedTerminalWorktreeIds((current) =>
+ haveSameIdSet(current, forceParkedWorktreeIds) ? current : forceParkedWorktreeIds
+ )
+ setEvictionExemptTerminalTabIds((current) =>
+ haveSameIdSet(current, nextEvictionExemptTabIds) ? current : nextEvictionExemptTabIds
+ )
+ const retentionTtlEligibleIds = new Set(
+ retentionBudgetCandidates
+ .filter((candidate) => !candidate.ordinaryParkingCovers && !candidate.hasPendingSpawnWork)
+ .map((candidate) => candidate.worktreeId)
+ )
for (const candidate of retentionCandidates) {
if (
@@ -911,8 +1103,15 @@ function Terminal(): React.JSX.Element | null {
const delayMs = getTerminalWorktreeColdParkRecheckDelayMs({
parkingEnabled: terminalParkingEnabled,
hiddenSinceMs: candidate.hiddenSinceMs,
+ parkCooldownUntilMs: candidate.parkCooldownUntilMs,
nowMs,
- ...overrides
+ ...overrides,
+ // Why: only retention candidates wake at the eviction TTL; everyone else keeps the ordinary deadlines.
+ ...(terminalRetentionBudgetEnabled && retentionTtlEligibleIds.has(candidate.worktreeId)
+ ? {
+ retentionTtlMs: overrides.retentionTtlMs ?? TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS
+ }
+ : {})
})
if (delayMs !== null && delayMs > 0) {
const worktreeId = candidate.worktreeId
@@ -932,6 +1131,8 @@ function Terminal(): React.JSX.Element | null {
tabsByWorktree,
terminalParkingEnabled,
terminalParkingRevision,
+ terminalRetentionBudgetEnabled,
+ terminalSshParkingEnabled,
workspaceSurfaces
])
// Why: gate on workspaceSessionReady so TerminalPane doesn't mount and spawn a duplicate PTY before reconnectPersistedTerminals() finishes.
@@ -1111,7 +1312,12 @@ function Terminal(): React.JSX.Element | null {
worktreeId: workspace.id,
tabId: tab.id
})
- if (!activityTerminalPortal) {
+ // Why the exempt exclusion: force-park keeps eviction-exempt tabs'
+ // panes mounted (a remount would orphan their live pty) — same
+ // per-tab carve-out as Activity portals, so no watcher owns them.
+ // The set is resolved in the force-park pass and holds only those
+ // worktrees' tabs.
+ if (!activityTerminalPortal && !evictionExemptTerminalTabIds.has(tab.id)) {
parkedTabIds.add(tab.id)
}
}
@@ -1149,6 +1355,7 @@ function Terminal(): React.JSX.Element | null {
activeTabIdByWorktree,
anyMountedWorktreeHasLayout,
backgroundMountRevision,
+ evictionExemptTerminalTabIds,
getEffectiveLayoutForWorktree,
groupsByWorktree,
effectiveParkedTerminalWorktreeIds,
@@ -1160,7 +1367,7 @@ function Terminal(): React.JSX.Element | null {
workspaceSessionReady,
workspaceSurfaces
])
- // Why: on host unmount no reconciliation effect runs again, so dispose every remaining parked watcher here.
+ // Why: on host unmount no reconciliation effect runs again, so dispose every remaining parked watcher.
useEffect(() => () => disposeAllParkedTerminalWatchers(), [])
// Auto-create first tab when worktree activates
useEffect(() => {
@@ -2146,6 +2353,7 @@ function Terminal(): React.JSX.Element | null {
isPresented={isPresented}
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
shouldColdParkTerminalPanes={shouldColdParkTerminalPanes}
+ isForceParked={forceParkedTerminalWorktreeIds.has(workspace.id)}
activityTerminalPortals={activityTerminalPortals}
backgroundMountTabIds={
backgroundMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null
@@ -2219,8 +2427,14 @@ function Terminal(): React.JSX.Element | null {
const isPresentedTerminalTab =
isPresented &&
(isActiveTerminalTab || tab.id === activeTabIdByWorktree[workspace.id])
- // Why: parking unmounts the view but keeps the PTY; an Activity portal stays mounted as a visible consumer.
- if (shouldColdParkTerminalPanes && !isActivityPortalTab) {
+ // Why: parking unmounts the view but keeps the PTY; an Activity portal stays
+ // mounted as a visible consumer, and a force-parked worktree's eviction-exempt
+ // tabs stay mounted because a remount would orphan their live pty.
+ if (
+ shouldColdParkTerminalPanes &&
+ !isActivityPortalTab &&
+ !evictionExemptTerminalTabIds.has(tab.id)
+ ) {
return null
}
const terminalPane = (
@@ -2418,6 +2632,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
isPresented,
shouldMeasureHiddenWorktree,
shouldColdParkTerminalPanes,
+ isForceParked,
activityTerminalPortals,
backgroundMountTabIds,
activationDeferredMountTabIds,
@@ -2431,6 +2646,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
isPresented: boolean
shouldMeasureHiddenWorktree: boolean
shouldColdParkTerminalPanes: boolean
+ isForceParked: boolean
activityTerminalPortals: ActivityTerminalPortalTarget[]
backgroundMountTabIds: ReadonlySet | null
activationDeferredMountTabIds: ReadonlySet | null
@@ -2474,6 +2690,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
isWorktreeActive={isVisible}
isWorktreePresented={isPresented}
coldParkTerminalPanes={shouldColdParkTerminalPanes}
+ isForceParked={isForceParked}
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
activityTerminalPortals={activityTerminalPortals}
backgroundMountTabIds={backgroundMountTabIds}
diff --git a/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx b/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx
new file mode 100644
index 00000000000..1da822d7cef
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx
@@ -0,0 +1,290 @@
+import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { useAppStore } from '../../store'
+import { SYNC_FIT_PANES_EVENT } from '@/constants/terminal'
+import { tabGroupBodyAnchorName } from '../tab-group/tab-group-body-anchor'
+import type { ActivityTerminalPortalTarget } from '../activity/activity-terminal-portal'
+import TerminalPane from './TerminalPane'
+import { closeTerminalTab } from '../terminal/terminal-tab-actions'
+import { shouldDeferParkedPtyExitTabClose } from './terminal-parked-tab-watchers'
+import {
+ FALLBACK_RECT_MIN_CHANGE_PX,
+ MIN_OVERLAY_FIT_HEIGHT_PX,
+ MIN_OVERLAY_FIT_WIDTH_PX,
+ shouldUseCssAnchorPositioning
+} from './terminal-overlay-positioning'
+
+type MeasuredFallbackRect = {
+ top: number
+ left: number
+ width: number
+ height: number
+}
+
+type TerminalOverlaySlotProps = {
+ terminalTabId: string
+ terminalGeneration: number | undefined
+ worktreeId: string
+ worktreePath: string
+ startupCwd: string | undefined
+ groupId: string | undefined
+ isWorktreeActive: boolean
+ isWorktreePresented: boolean
+ isVisible: boolean
+ isPresented: boolean
+ isActive: boolean
+ activityTerminalPortal: ActivityTerminalPortalTarget | null
+ onFocusOwningGroup: ((groupId: string) => void) | undefined
+ consumeSuppressedPtyExit: (ptyId: string) => boolean
+ leaveWorktreeIfEmpty: () => void
+ onInitialRenderSettled?: () => void
+}
+
+export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
+ terminalTabId,
+ terminalGeneration,
+ worktreeId,
+ worktreePath,
+ startupCwd,
+ groupId,
+ isWorktreeActive,
+ isWorktreePresented,
+ isVisible,
+ isPresented,
+ isActive,
+ activityTerminalPortal,
+ onFocusOwningGroup,
+ consumeSuppressedPtyExit,
+ leaveWorktreeIfEmpty,
+ onInitialRenderSettled
+}: TerminalOverlaySlotProps): React.JSX.Element {
+ const anchorName = groupId !== undefined ? tabGroupBodyAnchorName(groupId) : undefined
+ const overlayRef = useRef(null)
+ const [measuredFallbackRect, setMeasuredFallbackRect] = useState(
+ null
+ )
+ const [shouldMeasureHiddenStartup, setShouldMeasureHiddenStartup] = useState(
+ () => useAppStore.getState().pendingStartupByTabId[terminalTabId] !== undefined
+ )
+ useLayoutEffect(() => {
+ if (isVisible && shouldMeasureHiddenStartup) {
+ setShouldMeasureHiddenStartup(false)
+ }
+ }, [isVisible, shouldMeasureHiddenStartup])
+ useLayoutEffect(() => {
+ if (!anchorName || shouldUseCssAnchorPositioning() || !groupId) {
+ return
+ }
+
+ const findBody = (): HTMLElement | null => {
+ for (const candidate of document.querySelectorAll('[data-tab-group-body-id]')) {
+ if (candidate.dataset.tabGroupBodyId === groupId) {
+ return candidate
+ }
+ }
+ return null
+ }
+
+ const updateRect = (): void => {
+ const overlay = overlayRef.current
+ const parent = overlay?.parentElement
+ const body = findBody()
+ if (!parent || !body) {
+ setMeasuredFallbackRect(null)
+ return
+ }
+ const parentRect = parent.getBoundingClientRect()
+ const bodyRect = body.getBoundingClientRect()
+ const next: MeasuredFallbackRect = {
+ top: bodyRect.top - parentRect.top,
+ left: bodyRect.left - parentRect.left,
+ width: bodyRect.width,
+ height: bodyRect.height
+ }
+ // Why: ResizeObserver and xterm fit can otherwise amplify sub-pixel jitter forever.
+ setMeasuredFallbackRect((prev) =>
+ prev &&
+ Math.abs(prev.top - next.top) < FALLBACK_RECT_MIN_CHANGE_PX &&
+ Math.abs(prev.left - next.left) < FALLBACK_RECT_MIN_CHANGE_PX &&
+ Math.abs(prev.width - next.width) < FALLBACK_RECT_MIN_CHANGE_PX &&
+ Math.abs(prev.height - next.height) < FALLBACK_RECT_MIN_CHANGE_PX
+ ? prev
+ : next
+ )
+ }
+
+ updateRect()
+ const body = findBody()
+ const parent = overlayRef.current?.parentElement
+ const resizeObserver = new ResizeObserver(updateRect)
+ if (body) {
+ resizeObserver.observe(body)
+ }
+ if (parent) {
+ resizeObserver.observe(parent)
+ }
+ window.addEventListener('resize', updateRect)
+ return () => {
+ resizeObserver.disconnect()
+ window.removeEventListener('resize', updateRect)
+ }
+ }, [anchorName, groupId, isVisible])
+
+ useLayoutEffect(() => {
+ if (!isVisible || !anchorName) {
+ return
+ }
+ const dispatchFitIfMeasurable = (): void => {
+ const rect = overlayRef.current?.getBoundingClientRect()
+ if (
+ !rect ||
+ rect.width < MIN_OVERLAY_FIT_WIDTH_PX ||
+ rect.height < MIN_OVERLAY_FIT_HEIGHT_PX
+ ) {
+ return
+ }
+ window.dispatchEvent(new Event(SYNC_FIT_PANES_EVENT))
+ }
+
+ // Why: tab switches can resume visibility before anchor/fallback geometry
+ // settles. Re-fit only after the overlay has real dimensions so the PTY
+ // never stays pinned at a stale ~2-col width.
+ const frameId = requestAnimationFrame(() => {
+ dispatchFitIfMeasurable()
+ })
+ const retryId = window.setTimeout(() => {
+ dispatchFitIfMeasurable()
+ }, 50)
+ const settledRetryId = window.setTimeout(() => {
+ dispatchFitIfMeasurable()
+ }, 150)
+ return () => {
+ cancelAnimationFrame(frameId)
+ window.clearTimeout(retryId)
+ window.clearTimeout(settledRetryId)
+ }
+ }, [anchorName, isVisible, measuredFallbackRect])
+
+ const showPresentedTerminal = isPresented && (isWorktreeActive || isWorktreePresented)
+ const isInteractive = isVisible && isPresented && isWorktreePresented
+ const style: React.CSSProperties = useMemo(
+ () =>
+ anchorName && shouldUseCssAnchorPositioning()
+ ? {
+ position: 'absolute',
+ positionAnchor: anchorName,
+ top: `anchor(${anchorName} top)`,
+ left: `anchor(${anchorName} left)`,
+ width: `anchor-size(${anchorName} width)`,
+ height: `anchor-size(${anchorName} height)`,
+ display:
+ isVisible || showPresentedTerminal || shouldMeasureHiddenStartup ? 'flex' : 'none',
+ opacity: showPresentedTerminal ? 1 : 0,
+ pointerEvents: isInteractive ? 'auto' : 'none'
+ }
+ : anchorName
+ ? {
+ // Why: Chrome builds without CSS anchor positioning otherwise
+ // mount the terminal into a 0x0 overlay. Measure the tab-group
+ // body so the fallback does not cover the tab strip.
+ position: 'absolute',
+ top: measuredFallbackRect?.top ?? 32,
+ left: measuredFallbackRect?.left ?? 0,
+ width: measuredFallbackRect?.width ?? '100%',
+ height: measuredFallbackRect?.height ?? 'calc(100% - 32px)',
+ display:
+ isVisible || showPresentedTerminal || shouldMeasureHiddenStartup ? 'flex' : 'none',
+ opacity: showPresentedTerminal ? 1 : 0,
+ pointerEvents: isInteractive ? 'auto' : 'none'
+ }
+ : {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ width: 0,
+ height: 0,
+ display: 'none',
+ pointerEvents: 'none'
+ },
+ [
+ anchorName,
+ isInteractive,
+ isVisible,
+ measuredFallbackRect,
+ shouldMeasureHiddenStartup,
+ showPresentedTerminal
+ ]
+ )
+ const focusGroup = useCallback(() => {
+ if (groupId !== undefined && onFocusOwningGroup) {
+ onFocusOwningGroup(groupId)
+ }
+ }, [groupId, onFocusOwningGroup])
+
+ const terminalPane = (
+ {
+ if (consumeSuppressedPtyExit(ptyId)) {
+ return
+ }
+ // Why: a parked multi-leaf tab has no PaneManager to promote split
+ // siblings, so closing the tab here would kill them; the reveal
+ // remount handles dead PTYs per leaf instead.
+ if (shouldDeferParkedPtyExitTabClose(terminalTabId, ptyId)) {
+ return
+ }
+ closeTerminalTab(terminalTabId, {
+ reason: 'pty-exit',
+ lifecyclePtyId: ptyId,
+ onClosed: leaveWorktreeIfEmpty
+ })
+ }}
+ onCloseTab={() => {
+ // Why: route through closeTerminalTab (not the raw store closeTab) so a
+ // pinned tab hits the confirmation guard. The overlay's direct
+ // store.closeTab was the path that closed pinned terminals silently.
+ closeTerminalTab(terminalTabId, { onClosed: leaveWorktreeIfEmpty })
+ }}
+ onInitialRenderSettled={onInitialRenderSettled}
+ />
+ )
+
+ if (activityTerminalPortal) {
+ return createPortal(
+ terminalPane,
+ activityTerminalPortal.target,
+ `activity-terminal-${terminalTabId}`
+ )
+ }
+
+ return (
+
+ {terminalPane}
+ {/* The chat/terminal toggle now lives in the pane header's action cluster
+ (TerminalPaneHeaderOverlay), beside split/close — not as a separate
+ floating overlay. */}
+
- {terminalPane}
- {/* The chat/terminal toggle now lives in the pane header's action cluster
- (TerminalPaneHeaderOverlay), beside split/close — not as a separate
- floating overlay. */}
-
- )
-})
-
const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
worktreeId,
worktreePath,
isWorktreeActive,
isWorktreePresented = isWorktreeActive,
coldParkTerminalPanes = false,
+ isForceParked = false,
shouldMeasureHiddenWorktree = false,
activityTerminalPortals = EMPTY_ACTIVITY_PORTALS,
backgroundMountTabIds = null,
@@ -321,6 +36,8 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
isWorktreeActive: boolean
isWorktreePresented?: boolean
coldParkTerminalPanes?: boolean
+ /** Retention-budget force-park (C1 slice B): eviction-exempt tabs keep their mounted panes. */
+ isForceParked?: boolean
shouldMeasureHiddenWorktree?: boolean
activityTerminalPortals?: ActivityTerminalPortalTarget[]
/** Non-null for targeted background mounts: only these terminal tabs get a
@@ -378,6 +95,7 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
assignments,
isWorktreeActive,
coldParkTerminalPanes,
+ isForceParked,
shouldMeasureHiddenWorktree,
activityTerminalPortals,
activationDeferredMountTabIds
diff --git a/src/renderer/src/components/terminal-pane/command-code-done-settle.test.ts b/src/renderer/src/components/terminal-pane/command-code-done-settle.test.ts
new file mode 100644
index 00000000000..c71be186617
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/command-code-done-settle.test.ts
@@ -0,0 +1,113 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ COMMAND_CODE_OUTPUT_DONE_SETTLE_MS,
+ _resetCommandCodeDoneSettlesForTest,
+ cancelCommandCodeDoneSettle,
+ openCommandCodeDoneSettle,
+ setCommandCodeDoneSettleExecutor
+} from './command-code-done-settle'
+
+const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111'
+const OTHER_PANE_KEY = 'tab-2:22222222-2222-4222-8222-222222222222'
+
+describe('command code done settle window', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ _resetCommandCodeDoneSettlesForTest()
+ })
+
+ afterEach(() => {
+ _resetCommandCodeDoneSettlesForTest()
+ vi.useRealTimers()
+ })
+
+ it('settles through the registered executor at the deadline', () => {
+ const settle = vi.fn()
+ setCommandCodeDoneSettleExecutor(PANE_KEY, settle)
+
+ openCommandCodeDoneSettle(PANE_KEY, 'Fix the spinner')
+ vi.advanceTimersByTime(COMMAND_CODE_OUTPUT_DONE_SETTLE_MS - 1)
+ expect(settle).not.toHaveBeenCalled()
+
+ vi.advanceTimersByTime(1)
+ expect(settle).toHaveBeenCalledExactlyOnceWith('Fix the spinner')
+ })
+
+ // Why: this is the park/reveal contract — the deadline is the turn's, not the owner's.
+ it('keeps the original deadline when the owner is swapped mid-window', () => {
+ const parkedWatcherSettle = vi.fn()
+ const releaseParkedWatcher = setCommandCodeDoneSettleExecutor(PANE_KEY, parkedWatcherSettle)
+
+ openCommandCodeDoneSettle(PANE_KEY, 'Fix the spinner')
+ vi.advanceTimersByTime(COMMAND_CODE_OUTPUT_DONE_SETTLE_MS - 100)
+
+ // Reveal: the remounted pane registers before the parked watcher releases.
+ const revealedPaneSettle = vi.fn()
+ setCommandCodeDoneSettleExecutor(PANE_KEY, revealedPaneSettle)
+ releaseParkedWatcher()
+
+ vi.advanceTimersByTime(100)
+ expect(parkedWatcherSettle).not.toHaveBeenCalled()
+ expect(revealedPaneSettle).toHaveBeenCalledExactlyOnceWith('Fix the spinner')
+ })
+
+ // Why identity-checked: a late release from the previous owner must not unregister the successor.
+ it('ignores a stale release once a successor has registered', () => {
+ const releaseFirst = setCommandCodeDoneSettleExecutor(PANE_KEY, vi.fn())
+ const successorSettle = vi.fn()
+ setCommandCodeDoneSettleExecutor(PANE_KEY, successorSettle)
+ releaseFirst()
+
+ openCommandCodeDoneSettle(PANE_KEY, 'Fix the spinner')
+ vi.advanceTimersByTime(COMMAND_CODE_OUTPUT_DONE_SETTLE_MS)
+
+ expect(successorSettle).toHaveBeenCalledExactlyOnceWith('Fix the spinner')
+ })
+
+ it('drops the window when a working repaint cancels it', () => {
+ const settle = vi.fn()
+ setCommandCodeDoneSettleExecutor(PANE_KEY, settle)
+
+ openCommandCodeDoneSettle(PANE_KEY, 'Fix the spinner')
+ cancelCommandCodeDoneSettle(PANE_KEY)
+ vi.advanceTimersByTime(COMMAND_CODE_OUTPUT_DONE_SETTLE_MS * 2)
+
+ expect(settle).not.toHaveBeenCalled()
+ })
+
+ // Why: a pane torn down with no successor has no row worth completing.
+ it('fires harmlessly when every owner released before the deadline', () => {
+ const release = setCommandCodeDoneSettleExecutor(PANE_KEY, vi.fn())
+ openCommandCodeDoneSettle(PANE_KEY, 'Fix the spinner')
+ release()
+
+ expect(() => vi.advanceTimersByTime(COMMAND_CODE_OUTPUT_DONE_SETTLE_MS)).not.toThrow()
+ })
+
+ it('re-opening replaces the pending window rather than stacking a second one', () => {
+ const settle = vi.fn()
+ setCommandCodeDoneSettleExecutor(PANE_KEY, settle)
+
+ openCommandCodeDoneSettle(PANE_KEY, 'first turn')
+ vi.advanceTimersByTime(COMMAND_CODE_OUTPUT_DONE_SETTLE_MS - 100)
+ openCommandCodeDoneSettle(PANE_KEY, 'second turn')
+ vi.advanceTimersByTime(COMMAND_CODE_OUTPUT_DONE_SETTLE_MS)
+
+ expect(settle).toHaveBeenCalledExactlyOnceWith('second turn')
+ })
+
+ it('scopes windows per pane key', () => {
+ const settle = vi.fn()
+ const otherSettle = vi.fn()
+ setCommandCodeDoneSettleExecutor(PANE_KEY, settle)
+ setCommandCodeDoneSettleExecutor(OTHER_PANE_KEY, otherSettle)
+
+ openCommandCodeDoneSettle(PANE_KEY, 'mine')
+ openCommandCodeDoneSettle(OTHER_PANE_KEY, 'theirs')
+ cancelCommandCodeDoneSettle(PANE_KEY)
+ vi.advanceTimersByTime(COMMAND_CODE_OUTPUT_DONE_SETTLE_MS)
+
+ expect(settle).not.toHaveBeenCalled()
+ expect(otherSettle).toHaveBeenCalledExactlyOnceWith('theirs')
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/command-code-done-settle.ts b/src/renderer/src/components/terminal-pane/command-code-done-settle.ts
new file mode 100644
index 00000000000..69a0082df2d
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/command-code-done-settle.ts
@@ -0,0 +1,64 @@
+/**
+ * Cross-mount owner of Command Code's done-settle window.
+ *
+ * Why: park unmounts the pane and reveal disposes the parked watcher, both
+ * possibly mid-settle, and neither successor can re-observe the idle composer
+ * that opened the window. Owning the deadline here means park cannot strand the
+ * row at 'working' and reveal need not complete the turn early; only the row
+ * write (routing + title slot) stays with whoever currently owns the pane.
+ */
+
+// Mirrors the mounted and parked policies: the settle window must be identical
+// whether the pane is mounted or parked, or park/reveal changes completion timing.
+export const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500
+
+type CommandCodeDoneSettleExecutor = (normalizedPrompt: string) => void
+
+const executorByPaneKey = new Map()
+const timerByPaneKey = new Map>()
+
+/** Registers the current writer for this pane; returns its release.
+ * Last registrant wins by design: park and reveal each hand the pane to a new
+ * owner while the predecessor is still registered, so refusing the overwrite
+ * would leave the row with an owner that can no longer write it. */
+export function setCommandCodeDoneSettleExecutor(
+ paneKey: string,
+ execute: CommandCodeDoneSettleExecutor
+): () => void {
+ executorByPaneKey.set(paneKey, execute)
+ return () => {
+ // Why identity-checked: on reveal the remounted pane registers before the parked watcher releases.
+ if (executorByPaneKey.get(paneKey) === execute) {
+ executorByPaneKey.delete(paneKey)
+ }
+ }
+}
+
+export function openCommandCodeDoneSettle(paneKey: string, normalizedPrompt: string): void {
+ cancelCommandCodeDoneSettle(paneKey)
+ timerByPaneKey.set(
+ paneKey,
+ setTimeout(() => {
+ timerByPaneKey.delete(paneKey)
+ // Why optional: a pane torn down with no successor has no row worth completing.
+ executorByPaneKey.get(paneKey)?.(normalizedPrompt)
+ }, COMMAND_CODE_OUTPUT_DONE_SETTLE_MS)
+ )
+}
+
+export function cancelCommandCodeDoneSettle(paneKey: string): void {
+ const timer = timerByPaneKey.get(paneKey)
+ if (timer !== undefined) {
+ clearTimeout(timer)
+ timerByPaneKey.delete(paneKey)
+ }
+}
+
+/** Test-only: drops every pending window so suites cannot leak timers across cases. */
+export function _resetCommandCodeDoneSettlesForTest(): void {
+ for (const timer of timerByPaneKey.values()) {
+ clearTimeout(timer)
+ }
+ timerByPaneKey.clear()
+ executorByPaneKey.clear()
+}
diff --git a/src/renderer/src/components/terminal-pane/force-park-buffer-capture.test.ts b/src/renderer/src/components/terminal-pane/force-park-buffer-capture.test.ts
new file mode 100644
index 00000000000..4083fcface8
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/force-park-buffer-capture.test.ts
@@ -0,0 +1,61 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { RepoConnection } from '../../../../shared/workspace-session-terminal-buffers'
+import { captureForceParkedWorktreeBuffers } from './force-park-buffer-capture'
+import { shutdownBufferCaptures } from './shutdown-buffer-captures'
+
+const LOCAL_REPO: RepoConnection = {
+ id: 'repo',
+ connectionId: null,
+ executionHostId: 'local'
+}
+const SSH_REPO: RepoConnection = {
+ id: 'repo',
+ connectionId: 'conn-1',
+ executionHostId: null
+}
+
+afterEach(() => {
+ shutdownBufferCaptures.clear()
+})
+
+describe('captureForceParkedWorktreeBuffers', () => {
+ it('skips the capture for a local-repo worktree so a stored buffer survives force-park', () => {
+ const capture = vi.fn()
+ shutdownBufferCaptures.set('tab-1', capture)
+
+ const captured = captureForceParkedWorktreeBuffers({
+ worktreeId: 'repo::/repo/worktree',
+ tabIds: ['tab-1'],
+ repos: [LOCAL_REPO]
+ })
+
+ expect(captured).toBe(true)
+ expect(capture).not.toHaveBeenCalled()
+ })
+
+ it('captures remote worktree tabs without local buffers and reports full coverage', () => {
+ const capture = vi.fn()
+ shutdownBufferCaptures.set('tab-1', capture)
+
+ const captured = captureForceParkedWorktreeBuffers({
+ worktreeId: 'repo::/repo/worktree',
+ tabIds: ['tab-1'],
+ repos: [SSH_REPO]
+ })
+
+ expect(captured).toBe(true)
+ expect(capture).toHaveBeenCalledWith({ includeLocalBuffers: false })
+ })
+
+ it('reports an incomplete episode when a tab has no registered capture', () => {
+ shutdownBufferCaptures.set('tab-1', vi.fn())
+
+ const captured = captureForceParkedWorktreeBuffers({
+ worktreeId: 'repo::/repo/worktree',
+ tabIds: ['tab-1', 'tab-mid-remount'],
+ repos: [SSH_REPO]
+ })
+
+ expect(captured).toBe(false)
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/force-park-buffer-capture.ts b/src/renderer/src/components/terminal-pane/force-park-buffer-capture.ts
new file mode 100644
index 00000000000..14f73071169
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/force-park-buffer-capture.ts
@@ -0,0 +1,29 @@
+import {
+ shouldPreserveTerminalScrollbackBuffers,
+ type RepoConnection
+} from '../../../../shared/workspace-session-terminal-buffers'
+import { captureTerminalShutdownBuffersBestEffort } from './shutdown-buffer-captures'
+
+type ForceParkedWorktreeCaptureArgs = {
+ worktreeId: string
+ tabIds: readonly string[]
+ repos: readonly RepoConnection[]
+}
+
+/** Serialize a force-parked worktree's panes before eviction unmounts them.
+ * Returns whether the episode covered every tab; false leaves it unmarked so a later episode retries. */
+export function captureForceParkedWorktreeBuffers({
+ worktreeId,
+ tabIds,
+ repos
+}: ForceParkedWorktreeCaptureArgs): boolean {
+ // Why skip local worktrees: includeLocalBuffers:false serializes nothing for them, so the only
+ // effect left is setTabLayout replacing away a stored buffer (e.g. an exited setup pane's output).
+ if (!shouldPreserveTerminalScrollbackBuffers(worktreeId, repos)) {
+ return true
+ }
+ const { requested, captured } = captureTerminalShutdownBuffersBestEffort(tabIds, {
+ includeLocalBuffers: false
+ })
+ return captured === requested
+}
diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts
index f806b994e02..96bfd22b9f7 100644
--- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts
+++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalSideEffectFact } from '../../../../shared/terminal-side-effect-facts'
import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher'
+import type * as ParkedTerminalCommandStatus from './parked-terminal-command-status'
const PTY_ID = 'pty-parked-1'
const TAB_ID = 'tab-1'
@@ -34,6 +35,7 @@ type MockStoreState = {
markTerminalPaneUnread: ReturnType
setCacheTimerStartedAt: ReturnType
observeTerminalGitHubPullRequestLink: ReturnType
+ agentStatusByPaneKey: Record
}
const dispatchTerminalNotification = vi.fn()
@@ -43,6 +45,20 @@ vi.mock('./use-notification-dispatch', () => ({
dispatchTerminalNotification
}))
+// Why: command-status semantics are covered in parked-terminal-command-status.test.ts;
+// these tests only prove the watcher wires bytes/facts into the policy.
+const commandStatusPolicy = {
+ onCommandFinished: vi.fn(),
+ onCommandCodeWorking: vi.fn(),
+ onCommandCodeDone: vi.fn(),
+ dispose: vi.fn()
+}
+// Partial mock: readInFlightCommandCodeTurn stays real so detector seeding reads the store.
+vi.mock('./parked-terminal-command-status', async (importOriginal) => ({
+ ...(await importOriginal()),
+ createParkedTerminalCommandStatusPolicy: vi.fn(() => commandStatusPolicy)
+}))
+
vi.mock('@/lib/terminal-theme', () => ({
getSystemPrefersDark: () => true
}))
@@ -72,7 +88,8 @@ function createMockStoreState(): MockStoreState {
markTerminalTabUnread: vi.fn(),
markTerminalPaneUnread: vi.fn(),
setCacheTimerStartedAt: vi.fn(),
- observeTerminalGitHubPullRequestLink: vi.fn()
+ observeTerminalGitHubPullRequestLink: vi.fn(),
+ agentStatusByPaneKey: {}
}
}
@@ -110,6 +127,10 @@ describe('startParkedTerminalByteWatcher', () => {
vi.resetModules()
vi.useFakeTimers()
dispatchTerminalNotification.mockClear()
+ commandStatusPolicy.onCommandFinished.mockClear()
+ commandStatusPolicy.onCommandCodeWorking.mockClear()
+ commandStatusPolicy.onCommandCodeDone.mockClear()
+ commandStatusPolicy.dispose.mockClear()
onData = null
mockStoreState = createMockStoreState()
;(globalThis as { window: typeof window }).window = {
@@ -372,6 +393,65 @@ describe('startParkedTerminalByteWatcher', () => {
dispose()
})
+ it('scans OSC 133;D bytes into the parked command policy and disposes it with the watcher', async () => {
+ const { dispose } = await startWatcher()
+
+ emit('\x1b]133;D;0\x07')
+ expect(commandStatusPolicy.onCommandFinished).toHaveBeenCalledWith(0)
+
+ dispose()
+ expect(commandStatusPolicy.dispose).toHaveBeenCalledTimes(1)
+ })
+
+ it('feeds Command Code output through the parked byte detector', async () => {
+ const { dispose } = await startWatcher()
+
+ emit('# Command Code v0.27.2')
+ emit('⌘ Parsing...')
+
+ expect(commandStatusPolicy.onCommandCodeWorking).toHaveBeenCalledTimes(1)
+ dispose()
+ })
+
+ it('feeds a Command Code return to the idle composer through as done', async () => {
+ const { dispose } = await startWatcher()
+
+ emit('# Command Code v0.27.2\r\n')
+ emit('❯ Fix the spinner\r\n')
+ emit('\r\n❯ Ask your question...\r\n')
+
+ expect(commandStatusPolicy.onCommandCodeDone).toHaveBeenCalledWith('Fix the spinner')
+ dispose()
+ })
+
+ it('arms the Command Code scrape from a turn already in flight at park time', async () => {
+ // Why: the banner scrolled away long before the park, so only the live
+ // status row can tell the fresh detector this is a Command Code TUI.
+ mockStoreState.agentStatusByPaneKey = {
+ [PANE_KEY]: { state: 'working', prompt: 'Fix the spinner', agentType: 'command-code' }
+ }
+ const { dispose } = await startWatcher()
+
+ emit('\r\n❯ Ask your question...\r\n')
+
+ expect(commandStatusPolicy.onCommandCodeDone).toHaveBeenCalledWith('Fix the spinner')
+ dispose()
+ })
+
+ it('leaves the scrape unarmed when the parked pane has no in-flight Command Code turn', async () => {
+ mockStoreState.agentStatusByPaneKey = {
+ [PANE_KEY]: { state: 'done', prompt: 'Fix the spinner', agentType: 'command-code' }
+ }
+ const { dispose } = await startWatcher()
+
+ emit('\r\n❯ Ask your question...\r\n')
+ emit('⌘ Parsing...')
+
+ expect(commandStatusPolicy.onCommandCodeDone).not.toHaveBeenCalled()
+ expect(commandStatusPolicy.onCommandCodeWorking).not.toHaveBeenCalled()
+ dispose()
+ })
+
it('fires completion when seeded with a working title and the agent goes idle while parked', async () => {
// Why: the pane was working at park time; the watcher's fresh tracker
// must be seeded or this working→idle transition can never fire.
@@ -575,6 +655,32 @@ describe('startParkedTerminalByteWatcher', () => {
dispose()
})
+ it('routes command lifecycle facts into the parked command policy', async () => {
+ enableMainAuthority()
+ const { dispose } = await startWatcher()
+
+ await dispatchFacts([
+ { kind: 'command-finished', exitCode: 0 },
+ { kind: 'command-code-working', prompt: 'Fix the spinner' },
+ { kind: 'command-code-done', prompt: 'Fix the spinner' }
+ ])
+
+ expect(commandStatusPolicy.onCommandFinished).toHaveBeenCalledWith(0)
+ expect(commandStatusPolicy.onCommandCodeWorking).toHaveBeenCalledWith('Fix the spinner')
+ expect(commandStatusPolicy.onCommandCodeDone).toHaveBeenCalledWith('Fix the spinner')
+ dispose()
+ })
+
+ it('ignores replayed command lifecycle facts (no-attention-replay rule)', async () => {
+ enableMainAuthority()
+ const { dispose } = await startWatcher()
+
+ await dispatchFacts([{ kind: 'command-finished', exitCode: 0 }], { seq: 5, replay: true })
+
+ expect(commandStatusPolicy.onCommandFinished).not.toHaveBeenCalled()
+ dispose()
+ })
+
it('does not request a title snapshot for an ordinary parked watcher', async () => {
enableMainAuthority()
const getSideEffectSnapshot = vi.fn()
diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts
index 1748cb8290f..c6cad81c63e 100644
--- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts
+++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts
@@ -16,6 +16,12 @@ import {
isAgentTaskCompleteOsNotificationEnabledFromState,
isAgentTaskCompleteTrackingEnabledFromState
} from './agent-task-complete-policy'
+import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status'
+import { createOsc133CommandFinishedScanner } from '../../../../shared/terminal-osc133-command-finished'
+import {
+ createParkedTerminalCommandStatusPolicy,
+ readInFlightCommandCodeTurn
+} from './parked-terminal-command-status'
import { startParkedTerminalMode2031Responder } from './parked-terminal-mode2031-responder'
import { subscribeToPtyData } from './pty-data-sidecar-subscriptions'
import { createPtyOutputProcessor } from './pty-transport'
@@ -187,6 +193,16 @@ export function startParkedTerminalByteWatcher(
}
}
+ // Why: command-lifecycle signals drive store-level policy only (git nudge, SSH same-turn
+ // status drop, Command Code seed/settle); the pane-coupled parts stay with the mounted pane.
+ const commandStatusPolicy = createParkedTerminalCommandStatusPolicy({
+ ptyId,
+ worktreeId,
+ tabId,
+ paneId,
+ paneKey
+ })
+
// Why: with the authority switch on, the fact consumer is the single policy consumer — registering byte parsers too would double-fire bells.
const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({
settings: useAppStore.getState().settings,
@@ -214,12 +230,30 @@ export function startParkedTerminalByteWatcher(
const observeTerminalGitHubPRLink = mainSideEffectAuthority
? null
: createTerminalGitHubPRLinkDetector()
+ // Why (byte-parser mode only): mode parity — main's tracker emits these as facts; the byte
+ // path scans the same shared parsers the mounted kill-switch-off pane uses.
+ const commandFinishedScanner = mainSideEffectAuthority
+ ? null
+ : createOsc133CommandFinishedScanner(commandStatusPolicy.onCommandFinished)
+ // Why the seed: this detector is recreated per park cycle with no startup command
+ // to fast-arm it, and a Command Code TUI parked mid-turn is long past its banner —
+ // unseeded it would never scrape the turn's return to the idle composer.
+ const commandCodeOutputStatusDetector = mainSideEffectAuthority
+ ? null
+ : createCommandCodeOutputStatusDetector({
+ inFlightTurn: readInFlightCommandCodeTurn(paneKey),
+ onWorking: commandStatusPolicy.onCommandCodeWorking,
+ onDone: commandStatusPolicy.onCommandCodeDone
+ })
const unregisterFactConsumer = mainSideEffectAuthority
? registerTerminalSideEffectFactConsumer({
ptyId,
// Why: ordinary park already has a pane-owned title; the flag below requests a snapshot only when no pane did.
callbacks: {
...sideEffectCallbacks,
+ onCommandFinished: commandStatusPolicy.onCommandFinished,
+ onCommandCodeWorking: commandStatusPolicy.onCommandCodeWorking,
+ onCommandCodeDone: commandStatusPolicy.onCommandCodeDone,
onPrLink: (link) =>
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link),
// Why (gate mode only): the 2031 subscribe arrives as a fact, but the reply stays here — query authority stays with the view/watcher (invariant 6).
@@ -247,6 +281,8 @@ export function startParkedTerminalByteWatcher(
: subscribeToPtyData(ptyId, (data) => {
// Why: empty pane callbacks — no xterm to deliver bytes to, the watcher wants only the parser side effects.
processor.processData(data, {})
+ commandFinishedScanner?.scan(data)
+ commandCodeOutputStatusDetector?.observe(data)
if (observeTerminalGitHubPRLink) {
for (const link of observeTerminalGitHubPRLink(data)) {
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link)
@@ -266,6 +302,8 @@ export function startParkedTerminalByteWatcher(
unregisterFactConsumer?.()
// Why: clears tracker/timer/detector state so the watcher can't fire after the revealed pane's live parsers take over.
processor?.clearAccumulatedState()
+ commandFinishedScanner?.reset()
+ commandStatusPolicy.dispose()
clearBellNotificationTimer()
clearAgentTaskCompleteTimer()
pendingBellNotification = false
diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts
new file mode 100644
index 00000000000..c2479267e24
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts
@@ -0,0 +1,345 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
+
+const PTY_ID_LOCAL = 'pty-1'
+const PTY_ID_SSH = 'ssh:target-1@@pty-9'
+const TAB_ID = 'tab-1'
+const WORKTREE_ID = 'repo-1::/tmp/wt-1'
+const PANE_KEY = `${TAB_ID}:11111111-1111-4111-8111-111111111111`
+const PANE_ID = 1
+// Mirrors COMMAND_CODE_OUTPUT_DONE_SETTLE_MS.
+const DONE_SETTLE_MS = 1500
+
+const ROUTING = { connectionId: null }
+
+type MockStoreState = {
+ agentStatusByPaneKey: Record
+ runtimePaneTitlesByTabId: Record>
+ setAgentStatus: ReturnType
+ dropAgentStatus: ReturnType
+ clearAgentLaunchConfig: ReturnType
+}
+
+let mockStoreState: MockStoreState
+const dispatchTerminalCommandFinishedEvent = vi.fn()
+const resolveLiveAgentStatusConnectionRouting = vi.fn()
+const getConnectionIdFromState = vi.fn()
+
+vi.mock('@/store', () => ({
+ useAppStore: { getState: () => mockStoreState }
+}))
+vi.mock('@/hooks/terminal-command-finished-event', () => ({
+ dispatchTerminalCommandFinishedEvent
+}))
+vi.mock('@/lib/agent-status-connection-ownership', () => ({
+ resolveLiveAgentStatusConnectionRouting
+}))
+vi.mock('@/lib/connection-owner-resolution', () => ({
+ getConnectionIdFromState
+}))
+
+function makeMockStoreState(): MockStoreState {
+ return {
+ agentStatusByPaneKey: {},
+ runtimePaneTitlesByTabId: { [TAB_ID]: { [PANE_ID]: '✳ Build feature' } },
+ setAgentStatus: vi.fn(),
+ dropAgentStatus: vi.fn(),
+ clearAgentLaunchConfig: vi.fn()
+ }
+}
+
+function makeStatusEntry(overrides: Partial = {}): AgentStatusEntry {
+ return {
+ state: 'working',
+ prompt: 'build the feature',
+ agentType: 'claude',
+ updatedAt: 1000,
+ stateStartedAt: 1000,
+ ...overrides
+ } as AgentStatusEntry
+}
+
+async function createPolicy(ptyId: string) {
+ const { createParkedTerminalCommandStatusPolicy } =
+ await import('./parked-terminal-command-status')
+ return createParkedTerminalCommandStatusPolicy({
+ ptyId,
+ worktreeId: WORKTREE_ID,
+ tabId: TAB_ID,
+ paneId: PANE_ID,
+ paneKey: PANE_KEY
+ })
+}
+
+describe('createParkedTerminalCommandStatusPolicy', () => {
+ beforeEach(() => {
+ vi.resetModules()
+ vi.useFakeTimers()
+ dispatchTerminalCommandFinishedEvent.mockClear()
+ resolveLiveAgentStatusConnectionRouting.mockReset().mockReturnValue(ROUTING)
+ getConnectionIdFromState.mockReset().mockReturnValue(null)
+ mockStoreState = makeMockStoreState()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('seeds a Command Code working row with the current pane title', async () => {
+ const policy = await createPolicy(PTY_ID_LOCAL)
+
+ policy.onCommandCodeWorking('Fix the spinner')
+
+ expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith(
+ PANE_KEY,
+ { state: 'working', prompt: 'Fix the spinner', agentType: 'command-code' },
+ '✳ Build feature',
+ undefined,
+ ROUTING
+ )
+ policy.dispose()
+ })
+
+ it('writes nothing when connection routing does not resolve', async () => {
+ resolveLiveAgentStatusConnectionRouting.mockReturnValue(undefined)
+ const policy = await createPolicy(PTY_ID_LOCAL)
+
+ policy.onCommandCodeWorking('Fix the spinner')
+
+ expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
+ policy.dispose()
+ })
+
+ it('leaves a settled done row alone when working repeats the same prompt', async () => {
+ mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({
+ state: 'done',
+ prompt: 'Fix the spinner',
+ agentType: 'command-code'
+ })
+ const policy = await createPolicy(PTY_ID_LOCAL)
+
+ policy.onCommandCodeWorking('Fix the spinner')
+ policy.onCommandCodeWorking('')
+
+ expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
+ policy.dispose()
+ })
+
+ it('settles a still-working Command Code row to done after the settle window', async () => {
+ mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({
+ state: 'working',
+ prompt: 'Fix the spinner',
+ agentType: 'command-code'
+ })
+ const policy = await createPolicy(PTY_ID_LOCAL)
+
+ policy.onCommandCodeDone('Fix the spinner')
+ expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
+ vi.advanceTimersByTime(DONE_SETTLE_MS)
+
+ expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith(
+ PANE_KEY,
+ { state: 'done', prompt: 'Fix the spinner', agentType: 'command-code' },
+ '✳ Build feature',
+ undefined,
+ ROUTING
+ )
+ policy.dispose()
+ })
+
+ it('abandons the done settle when another agent owns the row by then', async () => {
+ mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ agentType: 'claude' })
+ const policy = await createPolicy(PTY_ID_LOCAL)
+
+ policy.onCommandCodeDone('Fix the spinner')
+ vi.advanceTimersByTime(DONE_SETTLE_MS)
+
+ expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
+ policy.dispose()
+ })
+
+ it('cancels a pending done settle when a working repaint arrives', async () => {
+ mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({
+ state: 'working',
+ prompt: 'Fix the spinner',
+ agentType: 'command-code'
+ })
+ const policy = await createPolicy(PTY_ID_LOCAL)
+
+ policy.onCommandCodeDone('Fix the spinner')
+ policy.onCommandCodeWorking('Fix the spinner')
+ vi.advanceTimersByTime(DONE_SETTLE_MS * 2)
+
+ const states = mockStoreState.setAgentStatus.mock.calls.map(([, payload]) => payload.state)
+ expect(states).toEqual(['working'])
+ policy.dispose()
+ })
+
+ // Why: reveal disposes the watcher mid-settle. Flushing would complete the turn early
+ // and the same-prompt-done guard then drops the genuine working repaint, so the window
+ // transfers to the remounted pane instead — the row stays 'working' across the boundary.
+ it('dispose hands the pending settle to the remounted pane instead of completing it early', async () => {
+ mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({
+ state: 'working',
+ prompt: 'Fix the spinner',
+ agentType: 'command-code'
+ })
+ const policy = await createPolicy(PTY_ID_LOCAL)
+ const { setCommandCodeDoneSettleExecutor } = await import('./command-code-done-settle')
+
+ policy.onCommandCodeDone('Fix the spinner')
+ vi.advanceTimersByTime(DONE_SETTLE_MS - 1)
+ const revealedPaneSettle = vi.fn()
+ setCommandCodeDoneSettleExecutor(PANE_KEY, revealedPaneSettle)
+ policy.dispose()
+
+ expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
+ vi.advanceTimersByTime(1)
+ expect(revealedPaneSettle).toHaveBeenCalledExactlyOnceWith('Fix the spinner')
+ })
+
+ // Why: the transferred window is still a settle window — a working repaint from the
+ // revealed pane before the original deadline must cancel it, not race it.
+ it('lets a working repaint after the handoff cancel the transferred settle', async () => {
+ mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({
+ state: 'working',
+ prompt: 'Fix the spinner',
+ agentType: 'command-code'
+ })
+ const policy = await createPolicy(PTY_ID_LOCAL)
+ const { cancelCommandCodeDoneSettle, setCommandCodeDoneSettleExecutor } =
+ await import('./command-code-done-settle')
+
+ policy.onCommandCodeDone('Fix the spinner')
+ const revealedPaneSettle = vi.fn()
+ setCommandCodeDoneSettleExecutor(PANE_KEY, revealedPaneSettle)
+ policy.dispose()
+ cancelCommandCodeDoneSettle(PANE_KEY)
+ vi.advanceTimersByTime(DONE_SETTLE_MS * 2)
+
+ expect(revealedPaneSettle).not.toHaveBeenCalled()
+ expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
+ })
+
+ it('dispose does not flush a settle a working repaint already cancelled', async () => {
+ mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({
+ state: 'working',
+ prompt: 'Fix the spinner',
+ agentType: 'command-code'
+ })
+ const policy = await createPolicy(PTY_ID_LOCAL)
+
+ policy.onCommandCodeDone('Fix the spinner')
+ policy.onCommandCodeWorking('Fix the spinner')
+ policy.dispose()
+
+ const states = mockStoreState.setAgentStatus.mock.calls.map(([, payload]) => payload.state)
+ expect(states).toEqual(['working'])
+ })
+
+ it('dispose does not flush when another agent owns the row by then', async () => {
+ mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ agentType: 'claude' })
+ const policy = await createPolicy(PTY_ID_LOCAL)
+
+ policy.onCommandCodeDone('Fix the spinner')
+ policy.dispose()
+
+ expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
+ })
+
+ it('dispose after the settle fired writes done exactly once', async () => {
+ mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({
+ state: 'working',
+ prompt: 'Fix the spinner',
+ agentType: 'command-code'
+ })
+ const policy = await createPolicy(PTY_ID_LOCAL)
+
+ policy.onCommandCodeDone('Fix the spinner')
+ vi.advanceTimersByTime(DONE_SETTLE_MS)
+ policy.dispose()
+
+ expect(mockStoreState.setAgentStatus).toHaveBeenCalledTimes(1)
+ })
+
+ it('nudges git UI on command finished for every PTY class', async () => {
+ const local = await createPolicy(PTY_ID_LOCAL)
+ local.onCommandFinished(0)
+ local.dispose()
+ const ssh = await createPolicy(PTY_ID_SSH)
+ ssh.onCommandFinished(0)
+ ssh.dispose()
+
+ expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledTimes(2)
+ expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledWith(WORKTREE_ID)
+ })
+
+ it('drops a same-turn status row on command finished for SSH PTYs only', async () => {
+ mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry()
+ const local = await createPolicy(PTY_ID_LOCAL)
+ local.onCommandFinished(0)
+ // Why: local drops need the mounted pane's foreground process-confirm ladder
+ // (leaked nested-shell 133;D protection), so the watcher must not drop them.
+ expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled()
+ local.dispose()
+
+ const ssh = await createPolicy(PTY_ID_SSH)
+ ssh.onCommandFinished(0)
+ expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(PANE_KEY)
+ ssh.dispose()
+ })
+
+ it('clears the launch registry on SSH command finished when no status row exists', async () => {
+ const ssh = await createPolicy(PTY_ID_SSH)
+
+ ssh.onCommandFinished(0)
+
+ expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(PANE_KEY)
+ expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled()
+ ssh.dispose()
+ })
+
+ it('does nothing after dispose', async () => {
+ const ssh = await createPolicy(PTY_ID_SSH)
+ ssh.dispose()
+
+ ssh.onCommandFinished(0)
+ ssh.onCommandCodeWorking('Fix the spinner')
+
+ expect(dispatchTerminalCommandFinishedEvent).not.toHaveBeenCalled()
+ expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
+ })
+})
+
+describe('readInFlightCommandCodeTurn', () => {
+ beforeEach(() => {
+ vi.resetModules()
+ mockStoreState = makeMockStoreState()
+ })
+
+ it('returns only a working command-code turn', async () => {
+ const { readInFlightCommandCodeTurn } = await import('./parked-terminal-command-status')
+
+ mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({
+ state: 'working',
+ prompt: 'Fix the spinner',
+ agentType: 'command-code'
+ })
+ expect(readInFlightCommandCodeTurn(PANE_KEY)).toEqual({ prompt: 'Fix the spinner' })
+
+ // Why 'done' excluded: a stale done row would arm the scrape against whatever
+ // process replaced the Command Code TUI.
+ mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({
+ state: 'done',
+ prompt: 'Fix the spinner',
+ agentType: 'command-code'
+ })
+ expect(readInFlightCommandCodeTurn(PANE_KEY)).toBeNull()
+
+ mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ agentType: 'claude' })
+ expect(readInFlightCommandCodeTurn(PANE_KEY)).toBeNull()
+
+ delete mockStoreState.agentStatusByPaneKey[PANE_KEY]
+ expect(readInFlightCommandCodeTurn(PANE_KEY)).toBeNull()
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts
new file mode 100644
index 00000000000..ff422e55de5
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts
@@ -0,0 +1,197 @@
+/**
+ * Parked-pane command-lifecycle status policy.
+ * Why: parking unmounts TerminalPane, so OSC 133;D and Command Code scrape signals went dark.
+ * This ports the store-level subset of pty-connection's handlers; the pane-coupled parts
+ * (foreground process-confirm ladder, key-intent interrupt inference) stay with the mounted pane.
+ */
+import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
+import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
+import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event'
+import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership'
+import { getConnectionIdFromState } from '@/lib/connection-owner-resolution'
+import { useAppStore } from '@/store'
+import {
+ cancelCommandCodeDoneSettle,
+ openCommandCodeDoneSettle,
+ setCommandCodeDoneSettleExecutor
+} from './command-code-done-settle'
+
+export type ParkedTerminalCommandStatusPolicy = {
+ onCommandFinished: (bestEffortExitCode: number | null) => void
+ onCommandCodeWorking: (prompt: string) => void
+ onCommandCodeDone: (prompt: string) => void
+ dispose: () => void
+}
+
+// Why only 'working': an in-flight turn is the one state a recreated detector can still
+// resolve, and it proves the Command Code TUI is live — a stale 'done' row would arm the
+// scrape against whatever process replaced it. Shared by parked watchers and the reveal
+// remount, which both recreate the detector long past the banner.
+export function readInFlightCommandCodeTurn(paneKey: string): { prompt: string } | null {
+ const entry = useAppStore.getState().agentStatusByPaneKey?.[paneKey]
+ if (entry?.agentType !== 'command-code' || entry.state !== 'working') {
+ return null
+ }
+ return { prompt: entry.prompt }
+}
+
+export function createParkedTerminalCommandStatusPolicy(options: {
+ ptyId: string
+ worktreeId: string
+ tabId: string
+ /** PaneManager pane id whose runtime-title slot the watcher writes; read for status title pairing. */
+ paneId: number
+ paneKey: string
+}): ParkedTerminalCommandStatusPolicy {
+ const { ptyId, worktreeId, tabId, paneId, paneKey } = options
+ let disposed = false
+ const clearCommandCodeOutputDoneTimer = (): void => cancelCommandCodeDoneSettle(paneKey)
+
+ const resolveRouting = (): ReturnType => {
+ if (disposed) {
+ return undefined
+ }
+ const state = useAppStore.getState()
+ return resolveLiveAgentStatusConnectionRouting({
+ state,
+ paneKey,
+ ptyId,
+ expectedConnectionId: getConnectionIdFromState(state, worktreeId)
+ })
+ }
+
+ // Port of pty-connection's dropCommandFinishedStatusIfSameTurn, minus the interrupt-inference
+ // option: parked panes receive no key events, so inference never has evidence here.
+ const dropCommandFinishedStatusIfSameTurn = (entry: AgentStatusEntry | undefined): void => {
+ const state = useAppStore.getState()
+ if (!entry) {
+ // Why: an Orca-started agent can exit before its first hook status; clear the launch
+ // registry on command exit like the mounted path does.
+ state.clearAgentLaunchConfig(paneKey)
+ return
+ }
+ const current = state.agentStatusByPaneKey[paneKey]
+ if (!current) {
+ state.clearAgentLaunchConfig(paneKey)
+ return
+ }
+ const unchanged =
+ current.state === entry.state &&
+ current.prompt === entry.prompt &&
+ current.updatedAt === entry.updatedAt &&
+ current.stateStartedAt === entry.stateStartedAt &&
+ current.agentType === entry.agentType
+ if (!unchanged) {
+ return
+ }
+ state.dropAgentStatus(paneKey)
+ }
+
+ // Complete the row only while it is still this turn's command-code/working entry.
+ const settleCommandCodeDone = (normalizedPrompt: string): void => {
+ const routing = resolveRouting()
+ if (!routing) {
+ return
+ }
+ const currentState = useAppStore.getState()
+ const currentEntry = currentState.agentStatusByPaneKey[paneKey]
+ if (currentEntry?.agentType !== 'command-code' || currentEntry.state !== 'working') {
+ return
+ }
+ const currentPrompt = currentEntry.prompt.trim()
+ if (currentPrompt && currentPrompt !== normalizedPrompt) {
+ return
+ }
+ const currentTitle = currentState.runtimePaneTitlesByTabId?.[tabId]?.[paneId]
+ currentState.setAgentStatus(
+ paneKey,
+ {
+ state: 'done',
+ prompt: currentPrompt || normalizedPrompt,
+ agentType: 'command-code'
+ },
+ currentTitle,
+ undefined,
+ routing
+ )
+ }
+
+ // Why the shared window: reveal disposes this watcher mid-settle and the remounted pane
+ // cannot re-observe the already-passed idle composer, so the deadline outlives whichever
+ // side owns the row — cancelling would strand 'working', flushing would complete the turn
+ // unrecoverably early (the same-prompt-done guard then drops a genuine working repaint).
+ const releaseCommandCodeDoneSettleExecutor = setCommandCodeDoneSettleExecutor(
+ paneKey,
+ settleCommandCodeDone
+ )
+
+ return {
+ onCommandFinished: (): void => {
+ if (disposed) {
+ return
+ }
+ // Why: the finished command may have moved HEAD or the index (an agent running
+ // `git checkout` in a parked worktree); nudge git UI now instead of waiting for a poll.
+ dispatchTerminalCommandFinishedEvent(worktreeId)
+ // Why: drop the same-turn status row only for SSH PTYs — exact parity with the mounted
+ // path, whose foreground tracker refuses SSH ids and drops un-probed. Local PTYs need
+ // pty-connection's process-confirm ladder to tell a leaked nested-shell 133;D from a
+ // real agent exit, so their drop stays with the mounted pane.
+ if (parseAppSshPtyId(ptyId) === null) {
+ return
+ }
+ dropCommandFinishedStatusIfSameTurn(useAppStore.getState().agentStatusByPaneKey[paneKey])
+ },
+
+ // Port of pty-connection's seedCommandCodeOutputWorkingStatus (store-level only).
+ onCommandCodeWorking: (prompt: string): void => {
+ clearCommandCodeOutputDoneTimer()
+ const routing = resolveRouting()
+ if (!routing) {
+ return
+ }
+ const currentState = useAppStore.getState()
+ const currentEntry = currentState.agentStatusByPaneKey[paneKey]
+ const currentTitle = currentState.runtimePaneTitlesByTabId?.[tabId]?.[paneId]
+ const normalizedPrompt = prompt.trim()
+ if (
+ currentEntry?.agentType === 'command-code' &&
+ currentEntry.state === 'done' &&
+ (!normalizedPrompt || normalizedPrompt === currentEntry.prompt.trim())
+ ) {
+ return
+ }
+ currentState.setAgentStatus(
+ paneKey,
+ {
+ state: 'working',
+ prompt:
+ normalizedPrompt || (currentEntry?.state === 'working' ? currentEntry.prompt : ''),
+ agentType: 'command-code'
+ },
+ currentTitle,
+ undefined,
+ routing
+ )
+ },
+
+ // Port of pty-connection's scheduleCommandCodeOutputDoneStatus: Command Code keeps rendering
+ // the composer while tools run, so only complete the row if no active repaint arrives.
+ onCommandCodeDone: (prompt: string): void => {
+ const normalizedPrompt = prompt.trim()
+ if (!normalizedPrompt) {
+ cancelCommandCodeDoneSettle(paneKey)
+ return
+ }
+ openCommandCodeDoneSettle(paneKey, normalizedPrompt)
+ },
+
+ dispose: (): void => {
+ // Why release, not flush: the settle window belongs to the turn and outlives this
+ // watcher, so the remounted pane inherits the remaining deadline and a working
+ // repaint arriving before it still cancels the completion.
+ releaseCommandCodeDoneSettleExecutor()
+ disposed = true
+ }
+ }
+}
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 1cf66787ac2..bdeca30e7f2 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
@@ -53,6 +53,18 @@ async function drainFakeTimerWork(limit = 20): Promise {
vi.clearAllTimers()
}
+// Why: a reveal remount reads the still-live park watcher entry at connect time to
+// tell itself apart from an in-place reattach; the host disposes it a beat later.
+async function parkTabForReveal(tabId: string, ptyId: string): Promise {
+ const { parkedWatchersByTabId } = await import('./terminal-parked-watcher-registry')
+ parkedWatchersByTabId.set(tabId, {
+ worktreeId: 'wt-1',
+ tabPtyId: ptyId,
+ paneIdByPtyId: new Map([[ptyId, 1]]),
+ disposersByPtyId: new Map([[ptyId, () => {}]])
+ })
+}
+
async function drainPendingTimeouts(pendingTimeouts: (() => void)[], limit = 100): Promise {
let iterations = 0
while (pendingTimeouts.length > 0) {
@@ -19859,6 +19871,89 @@ describe('connectPanePty', () => {
expect(api.pty.signal).toHaveBeenCalledWith('leaf-session', 'SIGWINCH')
})
+ it('falls back to relay replay when the SSH model snapshot stalls', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ const { SSH_REATTACH_MODEL_SNAPSHOT_TIMEOUT_MS } = await import('./ssh-reattach-model-restore')
+ const sshPtyId = toAppSshPtyId('conn-1', 'relay-pty-1')
+ const transport = createMockTransport(sshPtyId)
+ transport.connect.mockImplementation(async () => {
+ transport.getPtyId.mockReturnValue(sshPtyId)
+ return { id: sshPtyId, isReattach: true, replay: 'relay-fallback-output' }
+ })
+ transportFactoryQueue.push(transport)
+ vi.mocked(window.api.pty.getMainBufferSnapshot).mockReturnValue(new Promise(() => {}))
+ // Why parked: only a reveal remount probes main's model — the pane reads the
+ // still-live park watcher entry at connect time to tell the two apart.
+ await parkTabForReveal('tab-1', sshPtyId)
+
+ mockStoreState = {
+ ...mockStoreState,
+ tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
+ repos: [{ id: 'repo1', connectionId: 'conn-1' }],
+ deferredSshReconnectTargets: ['conn-1'],
+ deferredSshSessionIdsByTabId: { 'tab-1': sshPtyId }
+ }
+
+ const pane = createPane(1)
+ const { writes } = captureCallbackTerminalWrites(pane)
+ const deps = createDeps({
+ restoredLeafId: LEAF_1,
+ restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId }
+ })
+
+ connectPanePty(pane as never, createManager(1) as never, deps as never)
+ await flushAsyncTicks(20)
+
+ expect(window.api.pty.getMainBufferSnapshot).toHaveBeenCalledWith(sshPtyId, {
+ scrollbackRows: 5000
+ })
+ expect(writes).not.toContain('relay-fallback-output')
+
+ await new Promise((resolve) => setTimeout(resolve, SSH_REATTACH_MODEL_SNAPSHOT_TIMEOUT_MS + 25))
+ await flushAsyncTicks(20)
+
+ expect(writes).toContain('relay-fallback-output')
+ // Why: a stalled reveal must cost exactly one bounded probe — a re-probe
+ // would buy a second timeout window before the relay paint.
+ expect(window.api.pty.getMainBufferSnapshot).toHaveBeenCalledTimes(1)
+ })
+
+ // Why: the model probe exists to beat the relay's 100KiB tail on a park-reveal.
+ // An ordinary reattach (network reconnect, wake, reload) already holds that
+ // replay, so probing would only delay its paint by the timeout.
+ it('paints the relay replay without probing main when the reattach did not follow a park', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ const sshPtyId = toAppSshPtyId('conn-1', 'relay-pty-1')
+ const transport = createMockTransport(sshPtyId)
+ transport.connect.mockImplementation(async () => {
+ transport.getPtyId.mockReturnValue(sshPtyId)
+ return { id: sshPtyId, isReattach: true, replay: 'relay-reconnect-output' }
+ })
+ transportFactoryQueue.push(transport)
+ vi.mocked(window.api.pty.getMainBufferSnapshot).mockReturnValue(new Promise(() => {}))
+
+ mockStoreState = {
+ ...mockStoreState,
+ tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
+ repos: [{ id: 'repo1', connectionId: 'conn-1' }],
+ deferredSshReconnectTargets: ['conn-1'],
+ deferredSshSessionIdsByTabId: { 'tab-1': sshPtyId }
+ }
+
+ const pane = createPane(1)
+ const { writes } = captureCallbackTerminalWrites(pane)
+ const deps = createDeps({
+ restoredLeafId: LEAF_1,
+ restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId }
+ })
+
+ connectPanePty(pane as never, createManager(1) as never, deps as never)
+ await flushAsyncTicks(20)
+
+ expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled()
+ expect(writes).toContain('relay-reconnect-output')
+ })
+
it('does not auto-reconnect after a user cancels deferred SSH passphrase auth', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts
index 3962b06f890..27c52889e0d 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
@@ -243,6 +243,24 @@ import {
scheduleHiddenOutputRestore
} from './hidden-output-restore-scheduler'
import { resolveHiddenRestoreScrollbackRows } from './terminal-hidden-restore-scrollback'
+import {
+ buildMainModelSnapshotReplayWrites,
+ hasPositiveTerminalDimensions,
+ resolvePositiveTerminalDimensions
+} from './terminal-snapshot-replay-paint'
+import {
+ decideSshReattachPaintSource,
+ memoizeSshReattachModelSnapshotProbe,
+ resolveSshReattachModelSnapshotWithTimeout,
+ shouldFetchSshReattachModelSnapshot
+} from './ssh-reattach-model-restore'
+import { readInFlightCommandCodeTurn } from './parked-terminal-command-status'
+import {
+ cancelCommandCodeDoneSettle,
+ openCommandCodeDoneSettle,
+ setCommandCodeDoneSettleExecutor
+} from './command-code-done-settle'
+import { isTerminalTabParked } from './terminal-parked-watcher-registry'
import {
getExecutionHostIdForWorktree,
getSettingsForWorktreeRuntimeOwner
@@ -303,7 +321,6 @@ const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
const DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS = 31_000
const REMOTE_PTY_ID_PREFIX = 'remote:'
const PTY_CONNECT_DIAG_LIMIT = 200
-const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500
const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500
const MANUAL_AGENT_COMMAND_MAX_CHARS = 4096
const STARTUP_DRAFT_PASTE_QUIET_MS = 1500
@@ -1014,6 +1031,10 @@ export function connectPanePty(
// settles after remount must not remount its already-replaced successor.
const terminalRecoveryGeneration = captureTerminalPaneRecoveryGeneration(deps.tabId)
const terminalRecoveryInstance = registerTerminalPaneRecoveryInstance(deps.tabId)
+ // Why sampled here: the host disposes this tab's park watcher in the effect
+ // that follows this mount, so connect time is the only moment a pane can tell
+ // a reveal remount from an in-place reattach.
+ let mountFollowsTerminalPark = isTerminalTabParked(deps.tabId)
exposeE2eTerminalPtyOutputDebug()
let disposed = false
const structuralReplayCoordinator = createTerminalStructuralReplayCoordinator(pane.terminal)
@@ -2762,31 +2783,19 @@ export function connectPanePty(
)
}
- let commandCodeOutputDoneTimer: ReturnType | null = null
- const clearCommandCodeOutputDoneTimer = (): void => {
- if (commandCodeOutputDoneTimer !== null) {
- clearTimeout(commandCodeOutputDoneTimer)
- commandCodeOutputDoneTimer = null
- }
- }
- const scheduleCommandCodeOutputDoneStatus = (prompt: string): void => {
- clearCommandCodeOutputDoneTimer()
- const normalizedPrompt = prompt.trim()
- if (!normalizedPrompt) {
- return
- }
- // Why: Command Code keeps rendering the composer while tools run. Only
- // complete the row if no active status repaint arrives during this window.
- commandCodeOutputDoneTimer = setTimeout(() => {
- commandCodeOutputDoneTimer = null
- if (disposed) {
- return
- }
- const currentState = useAppStore.getState()
+ // Why the settle window lives outside this binding: park unmounts the pane
+ // mid-settle, so a pane-owned timer would be cancelled with nothing left to
+ // complete the turn — the row would stick at 'working'. Only the row write
+ // (routing + title slot) is pane-local; the deadline transfers to whichever
+ // owner (parked watcher or remounted pane) holds the pane next.
+ const releaseCommandCodeDoneSettleExecutor = setCommandCodeDoneSettleExecutor(
+ cacheKey,
+ (normalizedPrompt) => {
const routing = resolveCurrentAgentStatusRouting()
if (!routing) {
return
}
+ const currentState = useAppStore.getState()
const currentEntry = currentState.agentStatusByPaneKey[cacheKey]
if (currentEntry?.agentType !== 'command-code' || currentEntry.state !== 'working') {
return
@@ -2807,7 +2816,18 @@ export function connectPanePty(
undefined,
routing
)
- }, COMMAND_CODE_OUTPUT_DONE_SETTLE_MS)
+ }
+ )
+ const clearCommandCodeOutputDoneTimer = (): void => cancelCommandCodeDoneSettle(cacheKey)
+ const scheduleCommandCodeOutputDoneStatus = (prompt: string): void => {
+ const normalizedPrompt = prompt.trim()
+ if (!normalizedPrompt) {
+ cancelCommandCodeDoneSettle(cacheKey)
+ return
+ }
+ // Why: Command Code keeps rendering the composer while tools run. Only
+ // complete the row if no active status repaint arrives during this window.
+ openCommandCodeDoneSettle(cacheKey, normalizedPrompt)
}
const observeTerminalGitHubPRLink = createTerminalGitHubPRLinkDetector()
@@ -3517,6 +3537,10 @@ export function connectPanePty(
? null
: createCommandCodeOutputStatusDetector({
startupCommand: paneStartup?.command,
+ // Why the seed: a reveal remount recreates this detector long past the banner
+ // (and with no startup command); a turn parked mid-flight must still arm the
+ // scrape so its return to the idle composer completes the row.
+ inFlightTurn: readInFlightCommandCodeTurn(cacheKey),
onWorking: seedCommandCodeOutputWorkingStatus,
onDone: scheduleCommandCodeOutputDoneStatus
})
@@ -6845,11 +6869,7 @@ export function connectPanePty(
hiddenOutputSnapshotScrollRestore = scrollRestore
const colsBeforeReplay = pane.terminal.cols
const rowsBeforeReplay = pane.terminal.rows
- const hasSnapshotDimensions =
- Number.isFinite(snapshot.cols) &&
- Number.isFinite(snapshot.rows) &&
- snapshot.cols > 0 &&
- snapshot.rows > 0
+ const hasSnapshotDimensions = hasPositiveTerminalDimensions(snapshot.cols, snapshot.rows)
try {
await structuralReplayCoordinator.run(
async () => {
@@ -6883,19 +6903,12 @@ export function connectPanePty(
suppressStructuralReplayPtyResize = false
}
}
- if (!snapshot.alternateScreen) {
- // Why: \x1b[3J wipes xterm scrollback; alt-screen TUIs keep it in xterm, so clear only the normal buffer (mirrors pty-transport.ts).
- writeReplayData('\x1b[2J\x1b[3J\x1b[H')
- } else if (snapshot.scrollbackAnsi !== undefined) {
- // Why: SerializeAddon captures normal + alt buffers together; rebuild normal while active, then return to a clean alt frame.
- writeReplayData('\x1b[?1049l\x1b[2J\x1b[3J\x1b[H')
- writeReplayData(snapshot.scrollbackAnsi)
- writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H')
- } else {
- // Why: the snapshot's ?1049h no-ops when already on alt screen and skips blank cells; clear the alt buffer so the pre-hide frame can't bleed through blank cells (spares normal-buffer scrollback).
- writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H')
+ // Why shared: the SSH reattach model paint inlines the same
+ // choreography (coordinator nesting would deadlock there); one
+ // builder keeps the alt-screen branches from drifting.
+ for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot)) {
+ writeReplayData(replayChunk)
}
- writeReplayData(snapshot.data)
// Why: live agents own ?25l/?1004h; a forced ?1004l here would silence focus events until restart (agents enable focus reporting only at startup).
writeReplayData(
hasLiveAgentReattachStatusOrTitleSignal()
@@ -7613,7 +7626,55 @@ export function connectPanePty(
// Why: mobile streaming needs xterm's exact screen state; install the serializer + lastTitle source for main-process hydration parity.
registerPaneSerializerFor(ptyId)
- let reattachPayloadApplied = !hasStructuralReplay
+ // Why (C1 SSH parking): main's headless model holds ~5k rows for SSH ptys
+ // while the relay replay is a 100KiB raw-byte tail; prefer the model on
+ // reveal. Only a non-empty 'headless'-sourced snapshot qualifies — the
+ // renderer-serializer fallback has no mounted xterm after a park. The
+ // paint happens inline in the snapshot-branch style: applyMainBufferSnapshot
+ // would nest structuralReplayCoordinator.run inside the reattach task and
+ // deadlock on the coordinator's tail chain.
+ // Memoized: the prefetch and the payload task share one probe result, so a
+ // null prefetch can never buy a second timeout before the relay paint.
+ const fetchSshMainModelReattachSnapshot = memoizeSshReattachModelSnapshotProbe(
+ async (): Promise => {
+ const sshParkingEnabled =
+ useAppStore.getState().settings?.terminalSshViewParking !== false
+ if (!shouldFetchSshReattachModelSnapshot({ ptyId, sshParkingEnabled })) {
+ return null
+ }
+ const snapshot = await resolveSshReattachModelSnapshotWithTimeout(
+ window.api.pty.getMainBufferSnapshot(ptyId, {
+ scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback)
+ })
+ )
+ if (
+ !snapshot ||
+ decideSshReattachPaintSource({ ptyId, sshParkingEnabled, snapshot }) !==
+ 'main-model-snapshot'
+ ) {
+ return null
+ }
+ return snapshot
+ }
+ )
+ // Why consume-once: only the first reattach of a reveal remount may pay
+ // the probe; a later in-place reconnect on this same mount must not buy a
+ // second timeout before the relay paint.
+ const revealFollowsTerminalPark =
+ mountFollowsTerminalPark && connectResult?.isReattach === true
+ mountFollowsTerminalPark = false
+ // Why: a relay restart empties the replay buffer, but main's model may
+ // still hold the session — a park-reveal probes it even with no replay
+ // so the reveal is never blank when main has content. Prefetched (before
+ // the payload task) so the coordinator route covers the paint.
+ let prefetchedSshModelSnapshot: PtyBufferSnapshot | null = null
+ if (revealFollowsTerminalPark && !hasStructuralReplay) {
+ prefetchedSshModelSnapshot = await fetchSshMainModelReattachSnapshot()
+ if (!isCurrentReattachPayload()) {
+ return false
+ }
+ }
+ let reattachPayloadApplied = !hasStructuralReplay && prefetchedSshModelSnapshot === null
const applyReattachPayload = async (): Promise => {
if (!isCurrentReattachPayload()) {
return
@@ -7621,22 +7682,18 @@ export function connectPanePty(
if (connectResult?.snapshot) {
rememberReattachPayloadAgentSignal(connectResult.snapshot, { fullScreenReplay: true })
// Why: replay at the snapshot's own dimensions to avoid rewrapping soft-wrapped rows at a different column count (#7279); suppress the PTY forward so this layout-only resize doesn't SIGWINCH the remote TUI.
- const snapshotCols = connectResult.snapshotCols
- const snapshotRows = connectResult.snapshotRows
- const hasSnapshotDimensions =
- typeof snapshotCols === 'number' &&
- typeof snapshotRows === 'number' &&
- Number.isFinite(snapshotCols) &&
- Number.isFinite(snapshotRows) &&
- snapshotCols > 0 &&
- snapshotRows > 0
+ const snapshotDimensions = resolvePositiveTerminalDimensions(
+ connectResult.snapshotCols,
+ connectResult.snapshotRows
+ )
if (
- hasSnapshotDimensions &&
- (pane.terminal.cols !== snapshotCols || pane.terminal.rows !== snapshotRows)
+ snapshotDimensions &&
+ (pane.terminal.cols !== snapshotDimensions.cols ||
+ pane.terminal.rows !== snapshotDimensions.rows)
) {
suppressStructuralReplayPtyResize = true
try {
- pane.terminal.resize(snapshotCols, snapshotRows)
+ pane.terminal.resize(snapshotDimensions.cols, snapshotDimensions.rows)
} finally {
suppressStructuralReplayPtyResize = false
}
@@ -7658,19 +7715,72 @@ export function connectPanePty(
window.api.pty.ackColdRestore(ptyId)
}
}
- } else if (connectResult?.replay) {
- rememberReattachPayloadAgentSignal(connectResult.replay, { fullScreenReplay: true })
- // Relay replay may overlap xterm's pre-disconnect content; clear first to avoid duplication.
- writeReplayData('\x1b[2J\x1b[3J\x1b[H')
- // Why: raw relay replay may contain the app's own kitty pushes; re-arm with set semantics so redelivery can't grow the stack.
- kittyKeyboardModes.scanReplay(connectResult.replay)
- writeReplayData(connectResult.replay)
- writeReplayData(reattachReplayResetSequence(connectResult.replay))
- sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration)
- if (connectResult.coldRestore) {
- if (!isRemoteRuntimePtyId(ptyId)) {
+ } else if (connectResult?.replay || prefetchedSshModelSnapshot) {
+ // Why scoped to a park-reveal: the 100KiB relay tail loses scrollback the
+ // model still holds, but an in-place reattach (network reconnect, wake,
+ // reload) already has that replay in hand, so probing would only delay its
+ // paint by the timeout. Memoized, so this is never a second probe.
+ const modelSnapshot = revealFollowsTerminalPark
+ ? (prefetchedSshModelSnapshot ?? (await fetchSshMainModelReattachSnapshot()))
+ : null
+ if (!isCurrentReattachPayload()) {
+ return
+ }
+ if (modelSnapshot) {
+ // Why composed for scan/reset only: kitty + reset heuristics need
+ // the full byte stream; the actual writes go through the shared
+ // alt-screen choreography below (scrollbackAnsi is '' for
+ // normal-buffer snapshots, so composition matches data there).
+ const modelData = `${modelSnapshot.scrollbackAnsi ?? ''}${modelSnapshot.data}`
+ rememberReattachPayloadAgentSignal(modelData, { fullScreenReplay: true })
+ const modelCols = modelSnapshot.cols
+ const modelRows = modelSnapshot.rows
+ if (
+ hasPositiveTerminalDimensions(modelCols, modelRows) &&
+ (pane.terminal.cols !== modelCols || pane.terminal.rows !== modelRows)
+ ) {
+ // Why: replay at the snapshot's own dimensions (see the daemon-snapshot branch, #7279).
+ suppressStructuralReplayPtyResize = true
+ try {
+ pane.terminal.resize(modelCols, modelRows)
+ } finally {
+ suppressStructuralReplayPtyResize = false
+ }
+ }
+ kittyKeyboardModes.scanReplay(modelData)
+ // Why shared: park+reveal of an alt-screen TUI needs the same
+ // ?1049l/?1049h rebuild as applyMainBufferSnapshot (main strips
+ // the ?1049h marker when splitting scrollbackAnsi) — inlined here
+ // because nesting structuralReplayCoordinator would deadlock.
+ for (const replayChunk of buildMainModelSnapshotReplayWrites(modelSnapshot)) {
+ writeReplayData(replayChunk)
+ }
+ writeReplayData(reattachReplayResetSequence(modelData))
+ if (modelSnapshot.pendingEscapeTailAnsi) {
+ // Why last: re-arm the dangling mid-escape after the reset so the live continuation completes it (#7329).
+ writeReplayData(modelSnapshot.pendingEscapeTailAnsi)
+ }
+ // Why: main sampled its delivery backlog with the snapshot; the baseline drops/slices deferred and live chunks the snapshot already covers.
+ setRestoredSnapshotBaseline(ptyId, modelSnapshot)
+ recordRendererOrderedSeq(modelSnapshot)
+ sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration)
+ if (connectResult?.coldRestore && !isRemoteRuntimePtyId(ptyId)) {
window.api.pty.ackColdRestore(ptyId)
}
+ } else if (connectResult?.replay) {
+ rememberReattachPayloadAgentSignal(connectResult.replay, { fullScreenReplay: true })
+ // Relay replay may overlap xterm's pre-disconnect content; clear first to avoid duplication.
+ writeReplayData('\x1b[2J\x1b[3J\x1b[H')
+ // Why: raw relay replay may contain the app's own kitty pushes; re-arm with set semantics so redelivery can't grow the stack.
+ kittyKeyboardModes.scanReplay(connectResult.replay)
+ writeReplayData(connectResult.replay)
+ writeReplayData(reattachReplayResetSequence(connectResult.replay))
+ sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration)
+ if (connectResult.coldRestore) {
+ if (!isRemoteRuntimePtyId(ptyId)) {
+ window.api.pty.ackColdRestore(ptyId)
+ }
+ }
}
} else if (connectResult?.coldRestore) {
let destinationRows = pane.terminal.rows
@@ -7692,23 +7802,19 @@ export function connectPanePty(
if (!isCurrentReattachPayload()) {
return
}
- const coldRestoreCols = connectResult.coldRestore.cols
- const coldRestoreRows = connectResult.coldRestore.rows
- const hasColdRestoreDimensions =
- typeof coldRestoreCols === 'number' &&
- typeof coldRestoreRows === 'number' &&
- Number.isFinite(coldRestoreCols) &&
- Number.isFinite(coldRestoreRows) &&
- coldRestoreCols > 0 &&
- coldRestoreRows > 0
+ const coldRestoreDimensions = resolvePositiveTerminalDimensions(
+ connectResult.coldRestore.cols,
+ connectResult.coldRestore.rows
+ )
if (
- hasColdRestoreDimensions &&
- (pane.terminal.cols !== coldRestoreCols || pane.terminal.rows !== coldRestoreRows)
+ coldRestoreDimensions &&
+ (pane.terminal.cols !== coldRestoreDimensions.cols ||
+ pane.terminal.rows !== coldRestoreDimensions.rows)
) {
// Why: recovered ANSI cursor positions belong to the checkpoint's grid; keep this layout-only resize from reaching the fresh PTY.
suppressStructuralReplayPtyResize = true
try {
- pane.terminal.resize(coldRestoreCols, coldRestoreRows)
+ pane.terminal.resize(coldRestoreDimensions.cols, coldRestoreDimensions.rows)
} finally {
suppressStructuralReplayPtyResize = false
}
@@ -7741,7 +7847,7 @@ export function connectPanePty(
schedulePendingStartupCommandDelivery()
}
}
- if (hasStructuralReplay) {
+ if (hasStructuralReplay || prefetchedSshModelSnapshot) {
await waitForTerminalReplayWritesParsed(pane.terminal)
if (!isCurrentReattachPayload()) {
return
@@ -7795,7 +7901,7 @@ export function connectPanePty(
window.api.pty.signal(reattachPtyId, 'SIGWINCH')
}
}
- if (hasStructuralReplay) {
+ if (hasStructuralReplay || prefetchedSshModelSnapshot) {
await structuralReplayCoordinator.run(applyReattachPayload, {
shouldRestore: isCurrentReattachPayload,
afterRestore: fitAfterReattachRestore
@@ -8614,7 +8720,9 @@ export function connectPanePty(
pendingTerminalInputWrite = null
interruptInference.dispose()
clearTitleOnlyInterruptTimer()
- clearCommandCodeOutputDoneTimer()
+ // Why release, not cancel: the pending settle belongs to the turn, not to
+ // this pane — a park mid-settle hands it to the parked watcher instead.
+ releaseCommandCodeDoneSettleExecutor()
if (shiftEnterReconfirmTimer !== null) {
clearTimeout(shiftEnterReconfirmTimer)
shiftEnterReconfirmTimer = null
diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts
index 3a71a811bc5..ea7d9d043e1 100644
--- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts
+++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts
@@ -892,6 +892,104 @@ describe('createIpcPtyTransport', () => {
}
})
+ it('bounds the deferred side-effect queue under a stalled drain, keeping the newest title and a pending bell', async () => {
+ vi.useFakeTimers()
+ try {
+ const { createPtyOutputProcessor, MAX_PENDING_PTY_SIDE_EFFECTS } =
+ await import('./pty-transport')
+ const onTitleChange = vi.fn()
+ const onBell = vi.fn()
+ const processor = createPtyOutputProcessor({ onTitleChange, onBell })
+ const callbacks = { onData: vi.fn() }
+ const total = MAX_PENDING_PTY_SIDE_EFFECTS * 4
+
+ // Why: the bell is queued first so the cap must evict it — the latch has to survive onto a newer entry.
+ processor.processData('\x07', callbacks)
+ for (let i = 0; i < total / 2; i++) {
+ processor.processData(`\x1b]0;cap-title-${i}\x07`, callbacks)
+ }
+ // Why: a paused drain (background shutdown window) must not disable the bound either.
+ processor.pausePendingSideEffects()
+ for (let i = total / 2; i < total; i++) {
+ processor.processData(`\x1b]0;cap-title-${i}\x07`, callbacks)
+ }
+
+ expect(onTitleChange).not.toHaveBeenCalled()
+ processor.flushPendingSideEffects()
+
+ // Why: exactly the cap survives — every older title was evicted, never applied.
+ expect(onTitleChange).toHaveBeenCalledTimes(MAX_PENDING_PTY_SIDE_EFFECTS)
+ expect(onTitleChange).toHaveBeenLastCalledWith(
+ `cap-title-${total - 1}`,
+ `cap-title-${total - 1}`
+ )
+ expect(onBell).toHaveBeenCalledTimes(1)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('collapses evicted agent-status payloads onto the survivor, keeping the newest', async () => {
+ vi.useFakeTimers()
+ try {
+ const {
+ createPtyOutputProcessor,
+ MAX_PENDING_PTY_SIDE_EFFECTS,
+ MAX_EVICTED_AGENT_STATUS_PAYLOAD_CARRY
+ } = await import('./pty-transport')
+ const onAgentStatus = vi.fn()
+ const processor = createPtyOutputProcessor({ onAgentStatus })
+ const callbacks = { onData: vi.fn() }
+ const total = MAX_PENDING_PTY_SIDE_EFFECTS * 2
+
+ for (let i = 0; i < total; i++) {
+ processor.processData(
+ `\x1b]9999;{"state":"working","prompt":"cap-status-${i}"}\x07`,
+ callbacks
+ )
+ }
+ processor.flushPendingSideEffects()
+
+ const delivered = onAgentStatus.mock.calls.map(([payload]) => payload.prompt)
+ expect(delivered.length).toBeLessThanOrEqual(
+ MAX_PENDING_PTY_SIDE_EFFECTS + MAX_EVICTED_AGENT_STATUS_PAYLOAD_CARRY
+ )
+ expect(delivered.at(-1)).toBe(`cap-status-${total - 1}`)
+ // Why: eviction collapse must preserve chronological delivery order.
+ expect(delivered).toEqual(
+ [...delivered].sort((a, b) => Number(a.slice(11)) - Number(b.slice(11)))
+ )
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('delivers every side effect in order when the queue stays below the cap', async () => {
+ vi.useFakeTimers()
+ try {
+ const { createPtyOutputProcessor } = await import('./pty-transport')
+ const onTitleChange = vi.fn()
+ const onBell = vi.fn()
+ const onAgentStatus = vi.fn()
+ const processor = createPtyOutputProcessor({ onTitleChange, onBell, onAgentStatus })
+ const callbacks = { onData: vi.fn() }
+
+ processor.processData('\x07', callbacks)
+ for (let i = 0; i < 100; i++) {
+ processor.processData(`\x1b]0;under-cap-${i}\x07`, callbacks)
+ }
+ processor.processData('\x1b]9999;{"state":"done","prompt":"done"}\x07', callbacks)
+ await vi.runAllTimersAsync()
+
+ expect(onBell).toHaveBeenCalledTimes(1)
+ expect(onTitleChange).toHaveBeenCalledTimes(100)
+ expect(onTitleChange).toHaveBeenLastCalledWith('under-cap-99', 'under-cap-99')
+ expect(onAgentStatus).toHaveBeenCalledWith({ state: 'done', prompt: 'done' })
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
it('still runs stale-title detection when an OSC status chunk has no title', async () => {
vi.useFakeTimers()
try {
diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts
index 7df7fc18ab5..6b777cbb21e 100644
--- a/src/renderer/src/components/terminal-pane/pty-transport.ts
+++ b/src/renderer/src/components/terminal-pane/pty-transport.ts
@@ -69,6 +69,13 @@ const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
const SSH_PTY_CONNECTION_MISMATCH_MARKER = 'belongs to SSH connection'
const STALE_TITLE_TIMEOUT = 3000 // ms before stale working title is cleared
const MAX_PTY_SIDE_EFFECTS_PER_DRAIN = 64
+// Why: background timer throttling clamps the drain to ~64 effects/s while an agent CLI can queue
+// hundreds/s, so an overnight minimized window otherwise grows this queue without bound (C1/H2).
+// 512 ≈ 8 drain ticks of catch-up latency once visible; entries are compact facts (≤1 title/payload).
+export const MAX_PENDING_PTY_SIDE_EFFECTS = 512
+// Why: agent status is last-wins store state, but payloads can carry KB-scale prompt/tool strings;
+// carry only the newest few through eviction so a status flood cannot re-grow what it evicted.
+export const MAX_EVICTED_AGENT_STATUS_PAYLOAD_CARRY = 16
type PtyOutputCallbacks = Parameters[0]['callbacks']
@@ -207,6 +214,38 @@ export function createPtyOutputProcessor({
sideEffectDrainTimer = setTimeout(drainPtySideEffects, 0)
}
+ // Why: oldest-first eviction at the cap. Evicted titles are safe to drop (titles are last-wins);
+ // bells and agent-status payloads collapse onto the next-oldest survivor so a pending bell latch
+ // and the newest statuses still apply on drain instead of vanishing.
+ function evictOldestPendingSideEffectsIfFull(): void {
+ while (pendingSideEffects.length - pendingSideEffectIndex >= MAX_PENDING_PTY_SIDE_EFFECTS) {
+ const evicted = pendingSideEffects[pendingSideEffectIndex]
+ if (!evicted) {
+ return
+ }
+ pendingSideEffectIndex += 1
+ // Why: mirror applyPtySideEffect's accounting so stale-probe arming doesn't stick past eviction.
+ pendingWorkingTitleSideEffects -= countWorkingTitles(evicted.titles)
+ if (pendingWorkingTitleSideEffects < 0) {
+ pendingWorkingTitleSideEffects = 0
+ }
+ const survivor = pendingSideEffects[pendingSideEffectIndex]
+ if (survivor) {
+ if (evicted.containsBell) {
+ survivor.containsBell = true
+ }
+ if (evicted.payloads.length > 0) {
+ const merged = evicted.payloads.concat(survivor.payloads)
+ survivor.payloads =
+ merged.length > MAX_EVICTED_AGENT_STATUS_PAYLOAD_CARRY
+ ? merged.slice(-MAX_EVICTED_AGENT_STATUS_PAYLOAD_CARRY)
+ : merged
+ }
+ }
+ compactPendingSideEffectsIfNeeded()
+ }
+ }
+
function enqueuePtySideEffect(next: PendingPtySideEffect): void {
const workingTitleCount = countWorkingTitles(next.titles)
const prior = pendingSideEffects.at(-1)
@@ -225,6 +264,7 @@ export function createPtyOutputProcessor({
pendingWorkingTitleSideEffects += workingTitleCount
return
}
+ evictOldestPendingSideEffectsIfFull()
pendingSideEffects.push(next)
pendingWorkingTitleSideEffects += workingTitleCount
}
diff --git a/src/renderer/src/components/terminal-pane/replayed-scrollback-store-release.test.ts b/src/renderer/src/components/terminal-pane/replayed-scrollback-store-release.test.ts
new file mode 100644
index 00000000000..06a0d610f24
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/replayed-scrollback-store-release.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest'
+import type { RepoConnection } from '../../../../shared/workspace-session-terminal-buffers'
+import { canReleaseReplayedScrollbackFromStore } from './replayed-scrollback-store-release'
+
+const LOCAL_REPO: RepoConnection = {
+ id: 'local-repo',
+ connectionId: null,
+ executionHostId: 'local'
+}
+const SSH_REPO: RepoConnection = {
+ id: 'ssh-repo',
+ connectionId: 'conn-1',
+ executionHostId: null
+}
+const RUNTIME_REPO: RepoConnection = {
+ id: 'runtime-repo',
+ connectionId: null,
+ executionHostId: 'runtime:env-1'
+}
+const REPOS = [LOCAL_REPO, SSH_REPO, RUNTIME_REPO]
+
+describe('canReleaseReplayedScrollbackFromStore', () => {
+ it("releases ref-backed and remote-repo replays but keeps a local worktree's only copy", () => {
+ expect(
+ canReleaseReplayedScrollbackFromStore({
+ hasScrollbackRefs: true,
+ worktreeId: 'local-repo::/local/worktree',
+ repos: REPOS
+ })
+ ).toBe(true)
+ expect(
+ canReleaseReplayedScrollbackFromStore({
+ hasScrollbackRefs: false,
+ worktreeId: 'ssh-repo::/ssh/worktree',
+ repos: REPOS
+ })
+ ).toBe(true)
+ expect(
+ canReleaseReplayedScrollbackFromStore({
+ hasScrollbackRefs: false,
+ worktreeId: 'runtime-repo::/runtime/worktree',
+ repos: REPOS
+ })
+ ).toBe(true)
+ expect(
+ canReleaseReplayedScrollbackFromStore({
+ hasScrollbackRefs: false,
+ worktreeId: 'local-repo::/local/worktree',
+ repos: REPOS
+ })
+ ).toBe(false)
+ })
+
+ it('releases for an unhydrated repo catalog, matching the capture guard that re-mints it', () => {
+ expect(
+ canReleaseReplayedScrollbackFromStore({
+ hasScrollbackRefs: false,
+ worktreeId: 'unknown-repo::/maybe-remote/worktree',
+ repos: REPOS
+ })
+ ).toBe(true)
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/replayed-scrollback-store-release.ts b/src/renderer/src/components/terminal-pane/replayed-scrollback-store-release.ts
new file mode 100644
index 00000000000..4d7b360f812
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/replayed-scrollback-store-release.ts
@@ -0,0 +1,22 @@
+import {
+ shouldPreserveTerminalScrollbackBuffers,
+ type RepoConnection
+} from '../../../../shared/workspace-session-terminal-buffers'
+
+type ReplayedScrollbackReleaseArgs = {
+ hasScrollbackRefs: boolean
+ worktreeId: string | undefined
+ repos: readonly RepoConnection[]
+}
+
+/** Whether a replayed pane may drop its store-held scrollback copy now that xterm owns the bytes.
+ * Inverse of the force-park capture guard: keep the copy only where nothing can re-create it. */
+export function canReleaseReplayedScrollbackFromStore({
+ hasScrollbackRefs,
+ worktreeId,
+ repos
+}: ReplayedScrollbackReleaseArgs): boolean {
+ // Refs re-hydrate from disk and remote/SSH worktrees re-serialize at the next park; a local
+ // worktree never re-mints its copy (includeLocalBuffers:false), so releasing it would lose it.
+ return hasScrollbackRefs || shouldPreserveTerminalScrollbackBuffers(worktreeId, repos)
+}
diff --git a/src/renderer/src/components/terminal-pane/shutdown-buffer-captures.test.ts b/src/renderer/src/components/terminal-pane/shutdown-buffer-captures.test.ts
new file mode 100644
index 00000000000..af003ff1258
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/shutdown-buffer-captures.test.ts
@@ -0,0 +1,39 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+ captureTerminalShutdownBuffersBestEffort,
+ shutdownBufferCaptures
+} from './shutdown-buffer-captures'
+
+afterEach(() => {
+ shutdownBufferCaptures.clear()
+})
+
+describe('captureTerminalShutdownBuffersBestEffort', () => {
+ it('continues after one tab capture throws', () => {
+ const failed = vi.fn(() => {
+ throw new Error('layout capture failed')
+ })
+ const succeeded = vi.fn()
+ shutdownBufferCaptures.set('tab-failed', failed)
+ shutdownBufferCaptures.set('tab-succeeded', succeeded)
+
+ expect(() =>
+ captureTerminalShutdownBuffersBestEffort(['tab-failed', 'tab-succeeded'], {
+ includeLocalBuffers: false
+ })
+ ).not.toThrow()
+ expect(failed).toHaveBeenCalledWith({ includeLocalBuffers: false })
+ expect(succeeded).toHaveBeenCalledWith({ includeLocalBuffers: false })
+ })
+
+ it('reports incomplete coverage when a tab throws or has no registered capture', () => {
+ shutdownBufferCaptures.set('registered', vi.fn())
+ shutdownBufferCaptures.set('throwing', () => {
+ throw new Error('layout capture failed')
+ })
+
+ expect(
+ captureTerminalShutdownBuffersBestEffort(['registered', 'throwing', 'unregistered'])
+ ).toEqual({ requested: 3, captured: 1 })
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/shutdown-buffer-captures.ts b/src/renderer/src/components/terminal-pane/shutdown-buffer-captures.ts
index f904fef6967..8e349586860 100644
--- a/src/renderer/src/components/terminal-pane/shutdown-buffer-captures.ts
+++ b/src/renderer/src/components/terminal-pane/shutdown-buffer-captures.ts
@@ -20,3 +20,25 @@ export const shutdownBufferCaptures = new Map<
string,
(options?: ShutdownBufferCaptureOptions) => void
>()
+
+/** Capture every mounted tab without letting one layout failure abort retention.
+ * Reports coverage so a caller can decide whether the episode is retryable. */
+export function captureTerminalShutdownBuffersBestEffort(
+ tabIds: readonly string[],
+ options?: ShutdownBufferCaptureOptions
+): { requested: number; captured: number } {
+ let captured = 0
+ for (const tabId of tabIds) {
+ const capture = shutdownBufferCaptures.get(tabId)
+ if (!capture) {
+ continue
+ }
+ try {
+ capture(options)
+ captured += 1
+ } catch {
+ // Buffer capture is optional recovery evidence; parking must still commit.
+ }
+ }
+ return { requested: tabIds.length, captured }
+}
diff --git a/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.test.ts b/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.test.ts
new file mode 100644
index 00000000000..faedd69c971
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.test.ts
@@ -0,0 +1,157 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { toAppSshPtyId } from '../../../../shared/ssh-pty-id'
+import {
+ decideSshReattachPaintSource,
+ memoizeSshReattachModelSnapshotProbe,
+ resolveSshReattachModelSnapshotWithTimeout,
+ shouldFetchSshReattachModelSnapshot
+} from './ssh-reattach-model-restore'
+
+// Why built, not literal: a hardcoded id would silently stop parsing as SSH if the
+// prefix/separator changed, turning these positive cases into fallback expectations.
+const SSH_PTY_ID = toAppSshPtyId('conn-1', 'relay-pty-1')
+const LOCAL_PTY_ID = 'repo::/worktree@@session-1'
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+describe('resolveSshReattachModelSnapshotWithTimeout', () => {
+ it('degrades a stalled snapshot probe to null', async () => {
+ vi.useFakeTimers()
+ const pending = new Promise(() => {})
+ const resolved = resolveSshReattachModelSnapshotWithTimeout(pending, 25)
+
+ await vi.advanceTimersByTimeAsync(25)
+ await expect(resolved).resolves.toBeNull()
+ })
+
+ it('passes through a prompt snapshot and degrades rejection to null', async () => {
+ await expect(
+ resolveSshReattachModelSnapshotWithTimeout(Promise.resolve('snapshot'), 25)
+ ).resolves.toBe('snapshot')
+ await expect(
+ resolveSshReattachModelSnapshotWithTimeout(Promise.reject(new Error('unavailable')), 25)
+ ).resolves.toBeNull()
+ })
+})
+
+describe('shouldFetchSshReattachModelSnapshot', () => {
+ it('fetches only for SSH ptys with SSH parking enabled', () => {
+ expect(
+ shouldFetchSshReattachModelSnapshot({ ptyId: SSH_PTY_ID, sshParkingEnabled: true })
+ ).toBe(true)
+ expect(
+ shouldFetchSshReattachModelSnapshot({ ptyId: SSH_PTY_ID, sshParkingEnabled: false })
+ ).toBe(false)
+ expect(
+ shouldFetchSshReattachModelSnapshot({ ptyId: LOCAL_PTY_ID, sshParkingEnabled: true })
+ ).toBe(false)
+ })
+})
+
+describe('memoizeSshReattachModelSnapshotProbe', () => {
+ it('remembers a null prefetch so the payload task never probes twice', async () => {
+ const probe = vi.fn().mockResolvedValue(null)
+ const fetchSnapshot = memoizeSshReattachModelSnapshotProbe(probe)
+
+ await expect(fetchSnapshot()).resolves.toBeNull()
+ await expect(fetchSnapshot()).resolves.toBeNull()
+
+ expect(probe).toHaveBeenCalledTimes(1)
+ })
+
+ it('returns the same resolved snapshot without re-probing', async () => {
+ const probe = vi.fn().mockResolvedValue({ data: 'screen' })
+ const fetchSnapshot = memoizeSshReattachModelSnapshotProbe(probe)
+
+ const first = await fetchSnapshot()
+ const second = await fetchSnapshot()
+
+ expect(first).toBe(second)
+ expect(probe).toHaveBeenCalledTimes(1)
+ })
+
+ it('single-flights concurrent callers onto one probe', async () => {
+ let resolveProbe: (value: string | null) => void = () => {}
+ const probe = vi.fn(() => new Promise((resolve) => (resolveProbe = resolve)))
+ const fetchSnapshot = memoizeSshReattachModelSnapshotProbe(probe)
+
+ const first = fetchSnapshot()
+ const second = fetchSnapshot()
+ resolveProbe(null)
+
+ await expect(first).resolves.toBeNull()
+ await expect(second).resolves.toBeNull()
+ expect(probe).toHaveBeenCalledTimes(1)
+ })
+})
+
+describe('decideSshReattachPaintSource', () => {
+ const headless = { data: 'screen', source: 'headless' as const }
+
+ it('paints from the main model only for a non-empty headless snapshot', () => {
+ expect(
+ decideSshReattachPaintSource({
+ ptyId: SSH_PTY_ID,
+ sshParkingEnabled: true,
+ snapshot: headless
+ })
+ ).toBe('main-model-snapshot')
+ })
+
+ it('degrades to relay replay on null, renderer-sourced, sourceless, or empty snapshots', () => {
+ for (const snapshot of [
+ null,
+ { data: 'screen', source: 'renderer' as const },
+ { data: 'screen' },
+ { data: '', source: 'headless' as const },
+ { data: '', scrollbackAnsi: '', pendingEscapeTailAnsi: '', source: 'headless' as const }
+ ]) {
+ expect(
+ decideSshReattachPaintSource({ ptyId: SSH_PTY_ID, sshParkingEnabled: true, snapshot })
+ ).toBe('relay-replay')
+ }
+ })
+
+ it('judges emptiness on the composed content, not the screen frame alone', () => {
+ // Why: an alt-screen model snapshot can hold all content in scrollbackAnsi
+ // with an empty screen.
+ expect(
+ decideSshReattachPaintSource({
+ ptyId: SSH_PTY_ID,
+ sshParkingEnabled: true,
+ snapshot: { data: '', scrollbackAnsi: 'history', source: 'headless' as const }
+ })
+ ).toBe('main-model-snapshot')
+ })
+
+ it('treats a dangling escape tail alone as unpaintable content', () => {
+ // Why: an incomplete escape sequence renders nothing, so painting it would
+ // blank the pane over a relay replay that still holds the session.
+ expect(
+ decideSshReattachPaintSource({
+ ptyId: SSH_PTY_ID,
+ sshParkingEnabled: true,
+ snapshot: { data: '', pendingEscapeTailAnsi: '\x1b[', source: 'headless' as const }
+ })
+ ).toBe('relay-replay')
+ })
+
+ it('never upgrades when the kill switch is off or the pty is not SSH', () => {
+ expect(
+ decideSshReattachPaintSource({
+ ptyId: SSH_PTY_ID,
+ sshParkingEnabled: false,
+ snapshot: headless
+ })
+ ).toBe('relay-replay')
+ expect(
+ decideSshReattachPaintSource({
+ ptyId: LOCAL_PTY_ID,
+ sshParkingEnabled: true,
+ snapshot: headless
+ })
+ ).toBe('relay-replay')
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.ts b/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.ts
new file mode 100644
index 00000000000..c641332758c
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.ts
@@ -0,0 +1,85 @@
+import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
+
+export type SshReattachPaintSource = 'main-model-snapshot' | 'relay-replay'
+
+export type SshReattachModelSnapshot = {
+ data: string
+ source?: 'headless' | 'renderer'
+ scrollbackAnsi?: string
+ pendingEscapeTailAnsi?: string
+}
+
+// Why: relay replay is already available during reattach; a stalled model
+// probe must not strand that fallback or hold live PTY bytes indefinitely.
+export const SSH_REATTACH_MODEL_SNAPSHOT_TIMEOUT_MS = 750
+
+export async function resolveSshReattachModelSnapshotWithTimeout(
+ snapshot: Promise,
+ timeoutMs = SSH_REATTACH_MODEL_SNAPSHOT_TIMEOUT_MS
+): Promise {
+ let timer: ReturnType | null = null
+ try {
+ return await Promise.race([
+ snapshot.catch(() => null),
+ new Promise((resolve) => {
+ timer = setTimeout(() => resolve(null), timeoutMs)
+ })
+ ])
+ } finally {
+ if (timer !== null) {
+ clearTimeout(timer)
+ }
+ }
+}
+
+/**
+ * Which payload paints an SSH reattach (C1 SSH-parking design gate). Only
+ * main's headless model is trusted: a 'renderer'-sourced snapshot serializes a
+ * mounted xterm, which no longer exists once the pane parked — anything but a
+ * non-empty headless snapshot degrades to the relay replay, never a blank paint.
+ * Emptiness is judged on the composed CONTENT (scrollback + screen): an
+ * alt-screen snapshot can carry all content in scrollbackAnsi with an empty
+ * screen frame.
+ */
+export function decideSshReattachPaintSource(args: {
+ ptyId: string
+ sshParkingEnabled: boolean
+ snapshot: SshReattachModelSnapshot | null
+}): SshReattachPaintSource {
+ if (!args.sshParkingEnabled || parseAppSshPtyId(args.ptyId) === null) {
+ return 'relay-replay'
+ }
+ if (!args.snapshot || args.snapshot.source !== 'headless') {
+ return 'relay-replay'
+ }
+ // Why the escape tail is excluded: a dangling mid-escape is not content — a
+ // model holding only one paints a blank pane, which this gate forbids.
+ // Accepted cost: a session that wrote only control-sequence preamble before
+ // parking always falls back to relay; the gate cannot tell it apart from a
+ // model that never received content, and relay replays that preamble anyway.
+ const contentLength = (args.snapshot.scrollbackAnsi?.length ?? 0) + args.snapshot.data.length
+ return contentLength === 0 ? 'relay-replay' : 'main-model-snapshot'
+}
+
+/** Skip the snapshot fetch entirely when the paint could never use it. */
+export function shouldFetchSshReattachModelSnapshot(args: {
+ ptyId: string
+ sshParkingEnabled: boolean
+}): boolean {
+ return args.sshParkingEnabled && parseAppSshPtyId(args.ptyId) !== null
+}
+
+/**
+ * Single-flight memo for the reattach model probe: at most one
+ * getMainBufferSnapshot per reattach attempt. A null prefetch is remembered so
+ * the payload task never buys a second probe timeout — it paints relay instead.
+ */
+export function memoizeSshReattachModelSnapshotProbe(
+ probe: () => Promise
+): () => Promise {
+ let inFlight: Promise | null = null
+ return () => {
+ inFlight ??= probe()
+ return inFlight
+ }
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.test.ts b/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.test.ts
new file mode 100644
index 00000000000..b5692be08b1
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.test.ts
@@ -0,0 +1,172 @@
+import { describe, expect, it } from 'vitest'
+import {
+ getTerminalTabColdParkRecheckDelayMs,
+ getTerminalWorktreeColdParkRecheckDelayMs
+} from './terminal-cold-park-recheck-deadlines'
+
+describe('getTerminalWorktreeColdParkRecheckDelayMs', () => {
+ it('returns the next cold-park policy deadline', () => {
+ expect(
+ getTerminalWorktreeColdParkRecheckDelayMs({
+ parkingEnabled: true,
+ hiddenSinceMs: null,
+ nowMs: 1_000,
+ coldParkDelayMs: 100,
+ hotRetainMs: 1_000
+ })
+ ).toBeNull()
+ expect(
+ getTerminalWorktreeColdParkRecheckDelayMs({
+ parkingEnabled: true,
+ hiddenSinceMs: 1_000,
+ nowMs: 1_050,
+ coldParkDelayMs: 100,
+ hotRetainMs: 1_000
+ })
+ ).toBe(50)
+ expect(
+ getTerminalWorktreeColdParkRecheckDelayMs({
+ parkingEnabled: true,
+ hiddenSinceMs: 1_000,
+ nowMs: 1_100,
+ coldParkDelayMs: 100,
+ hotRetainMs: 1_000
+ })
+ ).toBe(900)
+ expect(
+ getTerminalWorktreeColdParkRecheckDelayMs({
+ parkingEnabled: true,
+ hiddenSinceMs: 1_000,
+ nowMs: 2_000,
+ coldParkDelayMs: 100,
+ hotRetainMs: 1_000
+ })
+ ).toBeNull()
+ })
+
+ it('schedules no recheck when the settings kill switch disables parking', () => {
+ expect(
+ getTerminalWorktreeColdParkRecheckDelayMs({
+ parkingEnabled: false,
+ hiddenSinceMs: 1_000,
+ nowMs: 1_050,
+ coldParkDelayMs: 100,
+ hotRetainMs: 1_000
+ })
+ ).toBeNull()
+ })
+
+ it('wakes at the retention TTL for budget candidates past the ordinary deadlines', () => {
+ expect(
+ getTerminalWorktreeColdParkRecheckDelayMs({
+ parkingEnabled: true,
+ hiddenSinceMs: 1_000,
+ nowMs: 2_500,
+ coldParkDelayMs: 100,
+ hotRetainMs: 1_000,
+ retentionTtlMs: 5_000
+ })
+ ).toBe(3_500)
+ expect(
+ getTerminalWorktreeColdParkRecheckDelayMs({
+ parkingEnabled: true,
+ hiddenSinceMs: 1_000,
+ nowMs: 2_500,
+ coldParkDelayMs: 100,
+ hotRetainMs: 1_000
+ })
+ ).toBeNull()
+ })
+
+ // Why: past the ordinary deadlines nothing else would wake the verdict
+ // effect, and the cool-down is what keeps the candidate out of the parked
+ // set — without this wakeup a measured worktree never re-parks.
+ it('wakes at the measure cool-down deadline when all ordinary deadlines have passed', () => {
+ expect(
+ getTerminalWorktreeColdParkRecheckDelayMs({
+ parkingEnabled: true,
+ hiddenSinceMs: 1_000,
+ nowMs: 2_500,
+ coldParkDelayMs: 100,
+ hotRetainMs: 1_000,
+ parkCooldownUntilMs: 2_600
+ })
+ ).toBe(100)
+ expect(
+ getTerminalWorktreeColdParkRecheckDelayMs({
+ parkingEnabled: true,
+ hiddenSinceMs: 1_000,
+ nowMs: 2_500,
+ coldParkDelayMs: 100,
+ hotRetainMs: 1_000,
+ parkCooldownUntilMs: 2_500
+ })
+ ).toBeNull()
+ })
+})
+
+describe('getTerminalTabColdParkRecheckDelayMs', () => {
+ it('returns the next terminal-tab cold-park policy deadline', () => {
+ expect(
+ getTerminalTabColdParkRecheckDelayMs({
+ parkingEnabled: true,
+ hiddenSinceMs: null,
+ nowMs: 1_000,
+ coldParkDelayMs: 100,
+ hotRetainMs: 1_000
+ })
+ ).toBeNull()
+ expect(
+ getTerminalTabColdParkRecheckDelayMs({
+ parkingEnabled: true,
+ hiddenSinceMs: 1_000,
+ nowMs: 1_050,
+ coldParkDelayMs: 100,
+ hotRetainMs: 1_000
+ })
+ ).toBe(50)
+ expect(
+ getTerminalTabColdParkRecheckDelayMs({
+ parkingEnabled: true,
+ hiddenSinceMs: 1_000,
+ nowMs: 1_100,
+ coldParkDelayMs: 100,
+ hotRetainMs: 1_000
+ })
+ ).toBe(900)
+ expect(
+ getTerminalTabColdParkRecheckDelayMs({
+ parkingEnabled: true,
+ hiddenSinceMs: 1_000,
+ nowMs: 2_000,
+ coldParkDelayMs: 100,
+ hotRetainMs: 1_000
+ })
+ ).toBeNull()
+ })
+
+ it('schedules no recheck when the settings kill switch disables parking', () => {
+ expect(
+ getTerminalTabColdParkRecheckDelayMs({
+ parkingEnabled: false,
+ hiddenSinceMs: 1_000,
+ nowMs: 1_050,
+ coldParkDelayMs: 100,
+ hotRetainMs: 1_000
+ })
+ ).toBeNull()
+ })
+
+ it('wakes at the measure cool-down deadline when all ordinary deadlines have passed', () => {
+ expect(
+ getTerminalTabColdParkRecheckDelayMs({
+ parkingEnabled: true,
+ hiddenSinceMs: 1_000,
+ nowMs: 2_500,
+ coldParkDelayMs: 100,
+ hotRetainMs: 1_000,
+ parkCooldownUntilMs: 2_750
+ })
+ ).toBe(250)
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.ts b/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.ts
new file mode 100644
index 00000000000..ba106054c87
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.ts
@@ -0,0 +1,72 @@
+/**
+ * Why: parking decisions change only at the cold-park, hot-retain, retention
+ * TTL, and post-measure cool-down deadlines, so the verdict effects schedule
+ * one recheck at the next deadline instead of polling.
+ */
+import {
+ TERMINAL_TAB_COLD_PARK_DELAY_MS,
+ TERMINAL_TAB_HOT_RETAIN_MS,
+ TERMINAL_WORKTREE_COLD_PARK_DELAY_MS,
+ TERMINAL_WORKTREE_HOT_RETAIN_MS
+} from './terminal-hidden-view-parking'
+
+function nextColdParkDeadlineDelayMs(args: {
+ parkingEnabled: boolean
+ hiddenSinceMs: number | null
+ nowMs: number
+ coldParkDelayMs: number
+ hotRetainMs: number
+ retentionTtlMs?: number
+ parkCooldownUntilMs?: number | null
+}): number | null {
+ if (!args.parkingEnabled || args.hiddenSinceMs === null) {
+ return null
+ }
+ const pendingDeadlines = [
+ args.hiddenSinceMs + args.coldParkDelayMs,
+ args.hiddenSinceMs + args.hotRetainMs,
+ ...(args.retentionTtlMs !== undefined ? [args.hiddenSinceMs + args.retentionTtlMs] : []),
+ // Why: the cool-down holds past-deadline candidates out of the parked set; without this wakeup nothing re-parks them.
+ ...(args.parkCooldownUntilMs != null ? [args.parkCooldownUntilMs] : [])
+ ].filter((deadlineMs) => deadlineMs > args.nowMs)
+ return pendingDeadlines.length === 0 ? null : Math.min(...pendingDeadlines) - args.nowMs
+}
+
+export function getTerminalWorktreeColdParkRecheckDelayMs(args: {
+ parkingEnabled: boolean
+ hiddenSinceMs: number | null
+ nowMs: number
+ coldParkDelayMs?: number
+ hotRetainMs?: number
+ /** Provided only for retention-budget candidates so their TTL wakes the verdict effect. */
+ retentionTtlMs?: number
+ parkCooldownUntilMs?: number | null
+}): number | null {
+ return nextColdParkDeadlineDelayMs({
+ parkingEnabled: args.parkingEnabled,
+ hiddenSinceMs: args.hiddenSinceMs,
+ nowMs: args.nowMs,
+ coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS,
+ hotRetainMs: args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS,
+ ...(args.retentionTtlMs !== undefined ? { retentionTtlMs: args.retentionTtlMs } : {}),
+ parkCooldownUntilMs: args.parkCooldownUntilMs
+ })
+}
+
+export function getTerminalTabColdParkRecheckDelayMs(args: {
+ parkingEnabled: boolean
+ hiddenSinceMs: number | null
+ nowMs: number
+ coldParkDelayMs?: number
+ hotRetainMs?: number
+ parkCooldownUntilMs?: number | null
+}): number | null {
+ return nextColdParkDeadlineDelayMs({
+ parkingEnabled: args.parkingEnabled,
+ hiddenSinceMs: args.hiddenSinceMs,
+ nowMs: args.nowMs,
+ coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS,
+ hotRetainMs: args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS,
+ parkCooldownUntilMs: args.parkCooldownUntilMs
+ })
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.test.ts b/src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.test.ts
new file mode 100644
index 00000000000..0560057b3dd
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/store', () => ({
+ useAppStore: { getState: () => ({ terminalLayoutsByTabId: {}, runtimePaneTitlesByTabId: {} }) }
+}))
+
+import { selectEvictionExemptTerminalTabLayoutKey } from './terminal-eviction-exempt-tabs'
+
+const TAB_ID = 'tab-1'
+const LEAF_ID = '11111111-1111-4111-8111-111111111111'
+const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
+const PTY_ID = 'repo::/worktree@@session-1'
+const TABS = [{ id: TAB_ID, ptyId: PTY_ID }]
+
+function layoutState(ptyIdsByLeafId: Record) {
+ return { terminalLayoutsByTabId: { [TAB_ID]: { ptyIdsByLeafId } } }
+}
+
+describe('selectEvictionExemptTerminalTabLayoutKey', () => {
+ // Why: the exempt memo is keyed on the tabs array, which a split leaves
+ // untouched — a key blind to layout PTYs would unmount the new live pane.
+ it('changes when a split adds a leaf pty', () => {
+ const single = layoutState({ [LEAF_ID]: PTY_ID })
+ expect(selectEvictionExemptTerminalTabLayoutKey(single, TABS)).toBe(
+ selectEvictionExemptTerminalTabLayoutKey(single, TABS)
+ )
+ expect(
+ selectEvictionExemptTerminalTabLayoutKey(
+ layoutState({ [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: 'pty-local-detached' }),
+ TABS
+ )
+ ).not.toBe(selectEvictionExemptTerminalTabLayoutKey(single, TABS))
+ })
+
+ it('changes when a leaf pty is re-minted under the same layout', () => {
+ expect(
+ selectEvictionExemptTerminalTabLayoutKey(
+ layoutState({ [LEAF_ID]: 'pty-local-detached' }),
+ TABS
+ )
+ ).not.toBe(selectEvictionExemptTerminalTabLayoutKey(layoutState({ [LEAF_ID]: PTY_ID }), TABS))
+ })
+
+ it('tolerates tabs with no persisted layout', () => {
+ expect(selectEvictionExemptTerminalTabLayoutKey({ terminalLayoutsByTabId: {} }, TABS)).toBe(
+ `${TAB_ID}=`
+ )
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.ts b/src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.ts
new file mode 100644
index 00000000000..3a11fab51fd
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.ts
@@ -0,0 +1,87 @@
+/**
+ * Eviction-exempt parked terminal tabs.
+ *
+ * Why: force-park unmounts restorable tabs under the hidden-worktree retention
+ * budget, but some live local PTYs cannot reattach on remount. Those tabs must
+ * keep their panes mounted so eviction does not orphan a live shell.
+ */
+import { useAppStore } from '@/store'
+import { isEvictionExemptTerminalPty } from './terminal-hidden-worktree-retention'
+import {
+ resolveParkedTerminalPaneCandidates,
+ type ParkableTerminalTabModel
+} from './terminal-parked-tab-watchers'
+
+/**
+ * Whether force-park must keep this tab's panes mounted: ANY of its pane PTYs is
+ * one a remount could not reattach. Resolved from the same pane candidates as
+ * canWatcherCoverParkedTerminalTab — tab.ptyId is only the first leaf's PTY, so
+ * a split tab whose SECOND leaf holds the unrestorable PTY fails coverage (and
+ * so becomes a retention candidate) yet would otherwise look exempt-free and
+ * unmount, orphaning that live shell. tab.ptyId stays in the union in case pane
+ * resolution misses it (no layout, no capture).
+ *
+ * Accepted residual: detection is per pane but retention is per TAB — the whole
+ * PaneManager stays mounted, so one exempt leaf also pins its restorable split
+ * siblings, and the retention budget cannot bound exempt tabs. Capping them is
+ * not an option (eviction orphans the live shell); the real fix is making those
+ * pty classes reattachable. Sibling TABS are unaffected — the worktree-level
+ * veto was removed (selectRetentionForceParkedTerminalWorktrees never consults
+ * tab exemptions).
+ */
+export function isEvictionExemptTerminalTab(
+ tab: ParkableTerminalTabModel,
+ worktreeId: string
+): boolean {
+ if (isEvictionExemptTerminalPty(tab.ptyId, worktreeId)) {
+ return true
+ }
+ return resolveParkedTerminalPaneCandidates(tab, useAppStore.getState()).some((pane) =>
+ isEvictionExemptTerminalPty(pane.ptyId, worktreeId)
+ )
+}
+
+/**
+ * Eviction-exempt tab ids for one worktree's tabs, resolved in a single pass.
+ *
+ * Why: each isEvictionExemptTerminalTab call re-reads the store and can walk the
+ * layout tree, so render and watcher-sync consume a memoized set instead of
+ * re-asking per tab on every unrelated re-render.
+ */
+export function selectEvictionExemptTerminalTabIds(
+ worktreeId: string,
+ tabs: readonly ParkableTerminalTabModel[]
+): ReadonlySet {
+ const exemptTabIds = new Set()
+ for (const tab of tabs) {
+ if (isEvictionExemptTerminalTab(tab, worktreeId)) {
+ exemptTabIds.add(tab.id)
+ }
+ }
+ return exemptTabIds
+}
+
+type EvictionExemptTabLayoutState = {
+ terminalLayoutsByTabId: Record } | undefined>
+}
+
+/**
+ * Memo key over the store half of an exemption verdict: a split add or a
+ * re-minted leaf pty changes the layout's pane PTYs without changing the tabs
+ * array, and a memo keyed on tabs alone would keep serving an exempt set that
+ * misses the new pane — force-park would unmount it and orphan a live shell.
+ */
+export function selectEvictionExemptTerminalTabLayoutKey(
+ state: EvictionExemptTabLayoutState,
+ tabs: readonly ParkableTerminalTabModel[]
+): string {
+ return tabs
+ .map((tab) => {
+ const ptyIdsByLeafId = state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}
+ const leafPtys = Object.entries(ptyIdsByLeafId)
+ .map(([leafId, ptyId]) => `${leafId}:${ptyId}`)
+ .join(',')
+ return `${tab.id}=${leafPtys}`
+ })
+ .join('|')
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts
index 5c8026b74d0..e518829b15e 100644
--- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts
@@ -9,8 +9,7 @@ import {
TERMINAL_WORKTREE_PARK_DELAY_MS,
canParkTerminalTabRenderer,
canParkTerminalWorktreeRenderers,
- getTerminalTabColdParkRecheckDelayMs,
- getTerminalWorktreeColdParkRecheckDelayMs,
+ isParkRestorableTerminalPty,
isSnapshotBackedTerminalPty,
selectColdParkedTerminalTabs,
selectColdParkedTerminalWorktrees
@@ -22,6 +21,18 @@ describe('isSnapshotBackedTerminalPty', () => {
expect(isSnapshotBackedTerminalPty('wt-1@@session-1', 'wt-1')).toBe(true)
})
+ // Why: folder workspaces mint worktree-shaped ids; parking must treat them identically (project rule).
+ it('allows folder-workspace sessions owned by the workspace', () => {
+ const folderWorkspaceId =
+ 'repo-1::/Users/dev/proj::workspace:6f9619ff-8b86-4d01-b42d-00cf4fc964ff'
+ expect(isSnapshotBackedTerminalPty(`${folderWorkspaceId}@@session-1`, folderWorkspaceId)).toBe(
+ true
+ )
+ expect(isSnapshotBackedTerminalPty(`${folderWorkspaceId}@@session-1`, 'repo-1::/other')).toBe(
+ false
+ )
+ })
+
// Why: separator-less ids ('1', '2', 'pty-local-detached') come from the
// daemon-fail-open LocalPtyProvider and have no daemon session model —
// revealing a parked pane would silently respawn a fresh shell, so they
@@ -46,6 +57,32 @@ describe('isSnapshotBackedTerminalPty', () => {
})
})
+describe('isParkRestorableTerminalPty', () => {
+ const worktreeId = 'repo::/worktree'
+ const sshPolicy = { sshParkingEnabled: true }
+
+ it('accepts every snapshot-backed pty regardless of policy', () => {
+ expect(isParkRestorableTerminalPty(`${worktreeId}@@session-1`, worktreeId)).toBe(true)
+ expect(isParkRestorableTerminalPty(`${worktreeId}@@session-1`, worktreeId, sshPolicy)).toBe(
+ true
+ )
+ })
+
+ it('accepts SSH ptys only when the SSH-parking policy is enabled', () => {
+ expect(isParkRestorableTerminalPty('ssh:ssh-1@@pty-1', worktreeId, sshPolicy)).toBe(true)
+ expect(isParkRestorableTerminalPty('ssh:ssh-1@@pty-1', worktreeId)).toBe(false)
+ expect(
+ isParkRestorableTerminalPty('ssh:ssh-1@@pty-1', worktreeId, { sshParkingEnabled: false })
+ ).toBe(false)
+ })
+
+ it('rejects remote-runtime, fail-open, foreign, and null ptys under every policy', () => {
+ for (const ptyId of ['remote:env-1@@terminal-1', 'pty-local-detached', 'other@@s-1', null]) {
+ expect(isParkRestorableTerminalPty(ptyId, worktreeId, sshPolicy)).toBe(false)
+ }
+ })
+})
+
describe('canParkTerminalWorktreeRenderers', () => {
const hiddenSinceMs = 1_000
const nowMs = hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS
@@ -65,6 +102,24 @@ describe('canParkTerminalWorktreeRenderers', () => {
expect(canParkTerminalWorktreeRenderers(base)).toBe(true)
})
+ it('parks a hidden SSH worktree only under the SSH restore policy', () => {
+ const sshArgs = {
+ ...base,
+ terminalTabs: [{ id: 'tab-1', ptyId: 'ssh:conn-1@@pty-1' }]
+ }
+ expect(canParkTerminalWorktreeRenderers(sshArgs)).toBe(false)
+ expect(
+ canParkTerminalWorktreeRenderers({ ...sshArgs, restorePolicy: { sshParkingEnabled: true } })
+ ).toBe(true)
+ expect(
+ canParkTerminalWorktreeRenderers({
+ ...sshArgs,
+ terminalTabs: [...sshArgs.terminalTabs, { id: 'tab-2', ptyId: 'remote:env-1@@t-1' }],
+ restorePolicy: { sshParkingEnabled: true }
+ })
+ ).toBe(false)
+ })
+
it('keeps a previously mounted v19 terminal eligible for ordinary parking', () => {
const legacyPtyId = 'repo::/worktree@@session-1'
clearTerminalProviderSnapshotCapabilities()
@@ -112,6 +167,17 @@ describe('canParkTerminalWorktreeRenderers', () => {
).toBe(true)
})
+ // Why: hiddenSince survives a background-measure window, so a past-deadline
+ // worktree would otherwise re-park the instant the measure lease ends —
+ // remount/reattach thrash on every ~3s periodic probe.
+ it('holds an otherwise past-deadline candidate out of park until the measure cool-down ends', () => {
+ expect(canParkTerminalWorktreeRenderers({ ...base, parkCooldownUntilMs: nowMs + 1 })).toBe(
+ false
+ )
+ expect(canParkTerminalWorktreeRenderers({ ...base, parkCooldownUntilMs: nowMs })).toBe(true)
+ expect(canParkTerminalWorktreeRenderers({ ...base, parkCooldownUntilMs: null })).toBe(true)
+ })
+
it('keeps the renderer mounted when any terminal lacks snapshot-backed restore', () => {
expect(
canParkTerminalWorktreeRenderers({
@@ -179,6 +245,11 @@ describe('canParkTerminalTabRenderer', () => {
canParkTerminalTabRenderer({ ...base, coldParkDelayMs: 100, nowMs: hiddenSinceMs + 100 })
).toBe(true)
})
+
+ it('holds an otherwise past-deadline tab out of park until the measure cool-down ends', () => {
+ expect(canParkTerminalTabRenderer({ ...base, parkCooldownUntilMs: base.nowMs + 1 })).toBe(false)
+ expect(canParkTerminalTabRenderer({ ...base, parkCooldownUntilMs: base.nowMs })).toBe(true)
+ })
})
describe('selectColdParkedTerminalWorktrees', () => {
@@ -399,6 +470,29 @@ describe('selectColdParkedTerminalTabs', () => {
expect(selected).toEqual(new Set(['tab-3']))
})
+ // Why worktree-scoped: measure windows mount every tab of the worktree, so
+ // one cool-down (not per-tab clocks) delays re-park after the lease ends.
+ it('selects nothing while the post-measure cool-down is active', () => {
+ const args = {
+ worktreeId: 'wt-1',
+ terminalTabs: [
+ localTab('tab-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
+ localTab('tab-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1),
+ localTab('tab-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2)
+ ],
+ pendingStartupByTabId: {},
+ parkingEnabled: true,
+ nowMs,
+ hotRetainLimit: 2
+ }
+ expect(selectColdParkedTerminalTabs({ ...args, parkCooldownUntilMs: nowMs + 1 })).toEqual(
+ new Set()
+ )
+ expect(selectColdParkedTerminalTabs({ ...args, parkCooldownUntilMs: nowMs })).toEqual(
+ new Set(['tab-3'])
+ )
+ })
+
it('cold-parks aged inactive local tabs even when under the retain limit', () => {
const selected = selectColdParkedTerminalTabs({
worktreeId: 'wt-1',
@@ -495,109 +589,3 @@ describe('selectColdParkedTerminalTabs', () => {
expect(selected).toEqual(new Set())
})
})
-
-describe('getTerminalWorktreeColdParkRecheckDelayMs', () => {
- it('returns the next cold-park policy deadline', () => {
- expect(
- getTerminalWorktreeColdParkRecheckDelayMs({
- parkingEnabled: true,
- hiddenSinceMs: null,
- nowMs: 1_000,
- coldParkDelayMs: 100,
- hotRetainMs: 1_000
- })
- ).toBeNull()
- expect(
- getTerminalWorktreeColdParkRecheckDelayMs({
- parkingEnabled: true,
- hiddenSinceMs: 1_000,
- nowMs: 1_050,
- coldParkDelayMs: 100,
- hotRetainMs: 1_000
- })
- ).toBe(50)
- expect(
- getTerminalWorktreeColdParkRecheckDelayMs({
- parkingEnabled: true,
- hiddenSinceMs: 1_000,
- nowMs: 1_100,
- coldParkDelayMs: 100,
- hotRetainMs: 1_000
- })
- ).toBe(900)
- expect(
- getTerminalWorktreeColdParkRecheckDelayMs({
- parkingEnabled: true,
- hiddenSinceMs: 1_000,
- nowMs: 2_000,
- coldParkDelayMs: 100,
- hotRetainMs: 1_000
- })
- ).toBeNull()
- })
-
- it('schedules no recheck when the settings kill switch disables parking', () => {
- expect(
- getTerminalWorktreeColdParkRecheckDelayMs({
- parkingEnabled: false,
- hiddenSinceMs: 1_000,
- nowMs: 1_050,
- coldParkDelayMs: 100,
- hotRetainMs: 1_000
- })
- ).toBeNull()
- })
-})
-
-describe('getTerminalTabColdParkRecheckDelayMs', () => {
- it('returns the next terminal-tab cold-park policy deadline', () => {
- expect(
- getTerminalTabColdParkRecheckDelayMs({
- parkingEnabled: true,
- hiddenSinceMs: null,
- nowMs: 1_000,
- coldParkDelayMs: 100,
- hotRetainMs: 1_000
- })
- ).toBeNull()
- expect(
- getTerminalTabColdParkRecheckDelayMs({
- parkingEnabled: true,
- hiddenSinceMs: 1_000,
- nowMs: 1_050,
- coldParkDelayMs: 100,
- hotRetainMs: 1_000
- })
- ).toBe(50)
- expect(
- getTerminalTabColdParkRecheckDelayMs({
- parkingEnabled: true,
- hiddenSinceMs: 1_000,
- nowMs: 1_100,
- coldParkDelayMs: 100,
- hotRetainMs: 1_000
- })
- ).toBe(900)
- expect(
- getTerminalTabColdParkRecheckDelayMs({
- parkingEnabled: true,
- hiddenSinceMs: 1_000,
- nowMs: 2_000,
- coldParkDelayMs: 100,
- hotRetainMs: 1_000
- })
- ).toBeNull()
- })
-
- it('schedules no recheck when the settings kill switch disables parking', () => {
- expect(
- getTerminalTabColdParkRecheckDelayMs({
- parkingEnabled: false,
- hiddenSinceMs: 1_000,
- nowMs: 1_050,
- coldParkDelayMs: 100,
- hotRetainMs: 1_000
- })
- ).toBeNull()
- })
-})
diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts
index f4b434c948b..92dcfd48b99 100644
--- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts
@@ -24,6 +24,8 @@ export type TerminalColdParkPolicyOverrides = {
coldParkDelayMs?: number
hotRetainMs?: number
hotRetainLimit?: number
+ retentionTtlMs?: number
+ retentionLimit?: number
}
export type ColdParkableTerminalTab = Pick
@@ -35,6 +37,10 @@ export type TerminalWorktreeColdParkCandidate = {
shouldMeasureHiddenWorktree: boolean
hasActivityTerminalPortal: boolean
hiddenSinceMs: number | null
+ /** Post-measure cool-down: hiddenSince survives a measure window (TTL/rank
+ * clock stays honest), but re-park waits for this deadline — else every ~3s
+ * measure lease on a past-deadline worktree thrashes remount → re-park. */
+ parkCooldownUntilMs?: number | null
}
export type TerminalTabColdParkCandidate = ColdParkableTerminalTab & {
@@ -50,9 +56,10 @@ function getPendingActivationSpawnCount(value: boolean | number | undefined): nu
return typeof value === 'number' && value > 0 ? value : 0
}
-// Why: parking relies on the daemon model snapshot to re-hydrate. Remote
-// runtime and SSH PTYs have no local snapshot in this phase, and a session id
-// minted for another worktree reattaches through a path parking cannot replay.
+// Why: snapshot-backed = local daemon session owned by this worktree (foreign
+// ids reattach through a path parking cannot replay). SSH is restorable too,
+// via isParkRestorableTerminalPty + main's headless model; only remote-runtime
+// ptys, which never transit main, stay unrestorable.
export function isSnapshotBackedTerminalPty(ptyId: string | null, worktreeId: string): boolean {
if (!ptyId) {
return false
@@ -67,6 +74,26 @@ export function isSnapshotBackedTerminalPty(ptyId: string | null, worktreeId: st
return separatorIdx !== -1 && ptyId.slice(0, separatorIdx) === worktreeId
}
+export type TerminalParkRestorePolicy = {
+ /** settings.terminalSshViewParking !== false — the C1 SSH-parking kill switch. */
+ sshParkingEnabled?: boolean
+}
+
+// Why: SSH bytes transit local main, so main's headless model (served over
+// pty:getMainBufferSnapshot) can re-hydrate a parked SSH reveal, with the
+// relay's replay buffer as fallback — fact-mode watchers cover side effects
+// either way. Remote-runtime ptys never transit main; they stay un-parkable.
+export function isParkRestorableTerminalPty(
+ ptyId: string | null,
+ worktreeId: string,
+ policy?: TerminalParkRestorePolicy
+): boolean {
+ if (isSnapshotBackedTerminalPty(ptyId, worktreeId)) {
+ return true
+ }
+ return policy?.sshParkingEnabled === true && ptyId !== null && parseAppSshPtyId(ptyId) !== null
+}
+
export function canParkTerminalWorktreeRenderers(args: {
worktreeId: string
terminalTabs: readonly ColdParkableTerminalTab[]
@@ -78,15 +105,18 @@ export function canParkTerminalWorktreeRenderers(args: {
shouldMeasureHiddenWorktree: boolean
hasActivityTerminalPortal: boolean
hiddenSinceMs: number | null
+ parkCooldownUntilMs?: number | null
nowMs: number
coldParkDelayMs?: number
+ restorePolicy?: TerminalParkRestorePolicy
}): boolean {
if (
!args.parkingEnabled ||
args.isVisible ||
args.shouldMeasureHiddenWorktree ||
args.hasActivityTerminalPortal ||
- args.hiddenSinceMs === null
+ args.hiddenSinceMs === null ||
+ (args.parkCooldownUntilMs != null && args.nowMs < args.parkCooldownUntilMs)
) {
return false
}
@@ -103,7 +133,7 @@ export function canParkTerminalWorktreeRenderers(args: {
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
return false
}
- return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId)
+ return isParkRestorableTerminalPty(tab.ptyId, args.worktreeId, args.restorePolicy)
})
}
@@ -114,13 +144,17 @@ export function canParkTerminalTabRenderer(args: {
parkingEnabled: boolean
nowMs: number
coldParkDelayMs?: number
+ /** Worktree-scoped post-measure cool-down (measure windows are per-worktree). */
+ parkCooldownUntilMs?: number | null
+ restorePolicy?: TerminalParkRestorePolicy
}): boolean {
const tab = args.terminalTab
if (
!args.parkingEnabled ||
tab.isVisible ||
tab.hasActivityTerminalPortal ||
- tab.hiddenSinceMs === null
+ tab.hiddenSinceMs === null ||
+ (args.parkCooldownUntilMs != null && args.nowMs < args.parkCooldownUntilMs)
) {
return false
}
@@ -133,10 +167,10 @@ export function canParkTerminalTabRenderer(args: {
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
return false
}
- return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId)
+ return isParkRestorableTerminalPty(tab.ptyId, args.worktreeId, args.restorePolicy)
}
-type ColdParkRetainCandidate = { id: string; hiddenSinceMs: number }
+export type ColdParkRetainCandidate = { id: string; hiddenSinceMs: number }
// Why: the single most-recently-hidden candidate is the view the user just
// switched away from; keeping it warm regardless of the TTL or cap means
@@ -161,7 +195,7 @@ function selectLastActiveRetainedId(candidates: ColdParkRetainCandidate[]): stri
// ids hidden past hotRetainMs or beyond the limit cold-park. The last-active
// id is exempt from both so returning to it never pays a remount. Ties sort by
// id so the selection is deterministic.
-function selectIdsBeyondHotRetain(
+export function selectIdsBeyondHotRetain(
candidates: ColdParkRetainCandidate[],
args: { nowMs: number; hotRetainMs: number; hotRetainLimit: number }
): Set {
@@ -197,6 +231,7 @@ export function selectColdParkedTerminalWorktrees(
pendingStartupByTabId: Readonly>
parkingEnabled: boolean
nowMs: number
+ restorePolicy?: TerminalParkRestorePolicy
} & TerminalColdParkPolicyOverrides
): Set {
if (!args.parkingEnabled) {
@@ -212,7 +247,8 @@ export function selectColdParkedTerminalWorktrees(
pendingStartupByTabId: args.pendingStartupByTabId,
parkingEnabled: args.parkingEnabled,
nowMs: args.nowMs,
- coldParkDelayMs
+ coldParkDelayMs,
+ ...(args.restorePolicy ? { restorePolicy: args.restorePolicy } : {})
})
) {
continue
@@ -233,6 +269,8 @@ export function selectColdParkedTerminalTabs(
pendingStartupByTabId: Readonly>
parkingEnabled: boolean
nowMs: number
+ parkCooldownUntilMs?: number | null
+ restorePolicy?: TerminalParkRestorePolicy
} & TerminalColdParkPolicyOverrides
): Set {
if (!args.parkingEnabled) {
@@ -249,7 +287,9 @@ export function selectColdParkedTerminalTabs(
pendingStartupByTabId: args.pendingStartupByTabId,
parkingEnabled: args.parkingEnabled,
nowMs: args.nowMs,
- coldParkDelayMs
+ coldParkDelayMs,
+ parkCooldownUntilMs: args.parkCooldownUntilMs,
+ ...(args.restorePolicy ? { restorePolicy: args.restorePolicy } : {})
})
) {
continue
@@ -262,55 +302,3 @@ export function selectColdParkedTerminalTabs(
hotRetainLimit: args.hotRetainLimit ?? TERMINAL_TAB_HOT_RETAIN_LIMIT
})
}
-
-// Why: parking decisions change only at the cold-park and hot-retain
-// deadlines, so callers schedule one recheck at the next deadline instead of
-// polling.
-function nextColdParkDeadlineDelayMs(args: {
- parkingEnabled: boolean
- hiddenSinceMs: number | null
- nowMs: number
- coldParkDelayMs: number
- hotRetainMs: number
-}): number | null {
- if (!args.parkingEnabled || args.hiddenSinceMs === null) {
- return null
- }
- const pendingDeadlines = [
- args.hiddenSinceMs + args.coldParkDelayMs,
- args.hiddenSinceMs + args.hotRetainMs
- ].filter((deadlineMs) => deadlineMs > args.nowMs)
- return pendingDeadlines.length === 0 ? null : Math.min(...pendingDeadlines) - args.nowMs
-}
-
-export function getTerminalWorktreeColdParkRecheckDelayMs(args: {
- parkingEnabled: boolean
- hiddenSinceMs: number | null
- nowMs: number
- coldParkDelayMs?: number
- hotRetainMs?: number
-}): number | null {
- return nextColdParkDeadlineDelayMs({
- parkingEnabled: args.parkingEnabled,
- hiddenSinceMs: args.hiddenSinceMs,
- nowMs: args.nowMs,
- coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS,
- hotRetainMs: args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS
- })
-}
-
-export function getTerminalTabColdParkRecheckDelayMs(args: {
- parkingEnabled: boolean
- hiddenSinceMs: number | null
- nowMs: number
- coldParkDelayMs?: number
- hotRetainMs?: number
-}): number | null {
- return nextColdParkDeadlineDelayMs({
- parkingEnabled: args.parkingEnabled,
- hiddenSinceMs: args.hiddenSinceMs,
- nowMs: args.nowMs,
- coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS,
- hotRetainMs: args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS
- })
-}
diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts
new file mode 100644
index 00000000000..3b1621b3fec
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts
@@ -0,0 +1,195 @@
+import { describe, expect, it } from 'vitest'
+import { TERMINAL_WORKTREE_PARK_DELAY_MS } from './terminal-hidden-view-parking'
+import {
+ TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS,
+ isEvictionExemptTerminalPty,
+ selectForceParkEvictableTabIds,
+ selectRetentionForceParkedTerminalWorktrees,
+ type TerminalWorktreeRetentionCandidate
+} from './terminal-hidden-worktree-retention'
+
+describe('isEvictionExemptTerminalPty', () => {
+ const worktreeId = 'repo::/worktree'
+
+ it('exempts only live local ptys a remount could not reattach', () => {
+ expect(isEvictionExemptTerminalPty('pty-local-detached', worktreeId)).toBe(true)
+ expect(isEvictionExemptTerminalPty('other::wt@@session-1', worktreeId)).toBe(true)
+ })
+
+ it('never exempts snapshot-backed, SSH, remote-runtime, or unbound ptys', () => {
+ expect(isEvictionExemptTerminalPty(`${worktreeId}@@session-1`, worktreeId)).toBe(false)
+ expect(isEvictionExemptTerminalPty('ssh:conn-1@@pty-1', worktreeId)).toBe(false)
+ expect(isEvictionExemptTerminalPty('remote:env-1@@t-1', worktreeId)).toBe(false)
+ expect(isEvictionExemptTerminalPty(null, worktreeId)).toBe(false)
+ })
+})
+
+describe('selectRetentionForceParkedTerminalWorktrees', () => {
+ const nowMs = 5_000_000
+
+ function retentionCandidate(
+ worktreeId: string,
+ hiddenSinceMs: number | null,
+ partial: Partial = {}
+ ): TerminalWorktreeRetentionCandidate {
+ return {
+ worktreeId,
+ hiddenSinceMs,
+ isVisible: false,
+ shouldMeasureHiddenWorktree: false,
+ hasActivityTerminalPortal: false,
+ ordinaryParkingCovers: false,
+ hasPendingSpawnWork: false,
+ ...partial
+ }
+ }
+
+ const base = {
+ parkingEnabled: true,
+ retentionBudgetEnabled: true,
+ nowMs
+ }
+
+ it('returns empty when either kill switch is off', () => {
+ const worktrees = [
+ retentionCandidate('wt-1', nowMs - TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS)
+ ]
+ expect(
+ selectRetentionForceParkedTerminalWorktrees({ ...base, worktrees, parkingEnabled: false })
+ ).toEqual(new Set())
+ expect(
+ selectRetentionForceParkedTerminalWorktrees({
+ ...base,
+ worktrees,
+ retentionBudgetEnabled: false
+ })
+ ).toEqual(new Set())
+ })
+
+ it('force-parks the least-recently-hidden candidates beyond the retention limit', () => {
+ const worktrees = [
+ retentionCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 3),
+ retentionCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2),
+ retentionCandidate('wt-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1),
+ retentionCandidate('wt-4', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS)
+ ]
+ // Why limit 2: wt-4 is last-active exempt, wt-3 fills the remaining slot; the two oldest evict.
+ expect(
+ selectRetentionForceParkedTerminalWorktrees({ ...base, worktrees, retentionLimit: 2 })
+ ).toEqual(new Set(['wt-1', 'wt-2']))
+ })
+
+ it('force-parks past the TTL even under the limit, sparing a last-active candidate inside it', () => {
+ const worktrees = [
+ retentionCandidate('wt-old', nowMs - TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS),
+ retentionCandidate('wt-recent', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS)
+ ]
+ expect(selectRetentionForceParkedTerminalWorktrees({ ...base, worktrees })).toEqual(
+ new Set(['wt-old'])
+ )
+ })
+
+ // Why: the last-active exemption keeps the warm cap's "return is always instant"
+ // promise, but carrying it into the eviction clock made "none past 45 minutes"
+ // false — a lone hidden un-parkable worktree stayed mounted for the whole session.
+ it('force-parks the last-active candidate once it passes the TTL (the exemption bounds the cap, not the clock)', () => {
+ const lone = [retentionCandidate('wt-lone', nowMs - TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS)]
+ expect(selectRetentionForceParkedTerminalWorktrees({ ...base, worktrees: lone })).toEqual(
+ new Set(['wt-lone'])
+ )
+ const insideTtl = [
+ retentionCandidate('wt-lone', nowMs - TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS + 1)
+ ]
+ expect(selectRetentionForceParkedTerminalWorktrees({ ...base, worktrees: insideTtl })).toEqual(
+ new Set()
+ )
+ })
+
+ it('never force-parks visible, measuring, portaled, covered, pending, or fresh candidates', () => {
+ const aged = nowMs - TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS
+ const worktrees = [
+ retentionCandidate('wt-visible', aged, { isVisible: true }),
+ retentionCandidate('wt-measure', aged, { shouldMeasureHiddenWorktree: true }),
+ retentionCandidate('wt-portal', aged, { hasActivityTerminalPortal: true }),
+ retentionCandidate('wt-covered', aged, { ordinaryParkingCovers: true }),
+ retentionCandidate('wt-pending', aged, { hasPendingSpawnWork: true }),
+ retentionCandidate('wt-fresh', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS + 1),
+ retentionCandidate('wt-unhidden', null)
+ ]
+ expect(selectRetentionForceParkedTerminalWorktrees({ ...base, worktrees })).toEqual(new Set())
+ })
+
+ // Why: hiddenSince (and with it TTL ranking) survives a measure window, so
+ // without the cool-down veto a measured past-TTL worktree would force-park
+ // again the instant the lease ends — the remount/reattach thrash Bug #2.
+ it('holds a candidate out of force-park until its post-measure cool-down ends', () => {
+ const aged = nowMs - TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS
+ // Why the recent sibling: it takes the last-active exemption, so the aged
+ // candidate's verdict is decided by the cool-down alone.
+ const recent = retentionCandidate('wt-recent', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS)
+ expect(
+ selectRetentionForceParkedTerminalWorktrees({
+ ...base,
+ worktrees: [
+ retentionCandidate('wt-measured', aged, { parkCooldownUntilMs: nowMs + 1 }),
+ recent
+ ]
+ })
+ ).toEqual(new Set())
+ expect(
+ selectRetentionForceParkedTerminalWorktrees({
+ ...base,
+ worktrees: [retentionCandidate('wt-measured', aged, { parkCooldownUntilMs: nowMs }), recent]
+ })
+ ).toEqual(new Set(['wt-measured']))
+ })
+
+ it('is idempotent and only grows as time advances (flip-loop dwell)', () => {
+ const worktrees = [
+ retentionCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 3),
+ retentionCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2),
+ retentionCandidate('wt-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1),
+ retentionCandidate('wt-4', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS)
+ ]
+ const first = selectRetentionForceParkedTerminalWorktrees({
+ ...base,
+ worktrees,
+ retentionLimit: 2
+ })
+ const second = selectRetentionForceParkedTerminalWorktrees({
+ ...base,
+ worktrees,
+ retentionLimit: 2
+ })
+ expect(second).toEqual(first)
+ // Why: with unchanged inputs, a later evaluation may only ADD members —
+ // a verdict that oscillates with time is the React-#185 ingredient.
+ for (const laterMs of [nowMs + 1_000, nowMs + TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS]) {
+ const later = selectRetentionForceParkedTerminalWorktrees({
+ ...base,
+ worktrees,
+ retentionLimit: 2,
+ nowMs: laterMs
+ })
+ for (const id of first) {
+ expect(later.has(id)).toBe(true)
+ }
+ }
+ })
+})
+
+describe('selectForceParkEvictableTabIds', () => {
+ const tabs = [{ id: 'tab-exempt' }, { id: 'tab-evictable' }]
+
+ it('drops eviction-exempt tabs from the capture and unmount set', () => {
+ expect(selectForceParkEvictableTabIds(tabs, (tab) => tab.id === 'tab-exempt')).toEqual([
+ 'tab-evictable'
+ ])
+ })
+
+ // Why: an all-exempt worktree still reports as force-parked while freeing nothing —
+ // the degenerate case a fleet-wide daemon fail-open produces, which the host logs.
+ it('yields nothing when every tab is exempt', () => {
+ expect(selectForceParkEvictableTabIds(tabs, () => true)).toEqual([])
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts
new file mode 100644
index 00000000000..86291b62ce4
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts
@@ -0,0 +1,130 @@
+import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
+import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
+import {
+ TERMINAL_WORKTREE_COLD_PARK_DELAY_MS,
+ isSnapshotBackedTerminalPty,
+ selectIdsBeyondHotRetain,
+ type ColdParkRetainCandidate,
+ type TerminalColdParkPolicyOverrides
+} from './terminal-hidden-view-parking'
+
+// Why these sizes: a retained hidden pane costs a measured ~2.5MB of V8 heap
+// at the 5k-row default scrollback and ~19MB at 50k (plus per-pane queues),
+// not the ~4-5MB per WORKTREE the warm cap assumed — so un-parkable worktrees
+// (pty classes parking can't restore) get a retention budget: at most 12 stay
+// mounted while hidden and none past 45 minutes, evicted least-recently-hidden
+// first via force-park. The TTL is absolute: the last-active exemption bounds
+// the cap, never the clock.
+// NOT covered by this bound: eviction-exempt TABS (isEvictionExemptTerminalPty
+// — live local ptys a remount would respawn, orphaning the shell). Their panes
+// stay mounted through a force-park at any age, so a fleet-wide daemon
+// fail-open can leave the budget freeing nothing; Terminal.tsx logs that
+// degenerate case rather than pretending the bound held.
+// Also NOT covered: per-pane scrollback size. Hidden-pane scrollback demotion
+// was intentionally removed — the bound is worktree count + TTL only, so a
+// spared worktree (last-active, exempt tabs) can hold full 50k-row scrollback
+// indefinitely. Accepted tradeoff: high-scrollback users rely on unmount
+// eviction, not demotion.
+export const TERMINAL_HIDDEN_WORKTREE_RETENTION_LIMIT = 12
+export const TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS = 45 * 60_000
+
+// Why: an eviction-exempt pty is a live local one a remount could not reattach
+// (daemon-fail-open separator-less ids, ptys minted under another worktree) — a
+// fresh spawn would orphan the live shell. Its TAB keeps its mounted pane when
+// the worktree force-parks (per-tab exclusion, mirroring Activity portals).
+// Per-PTY, not per-tab: the coverage veto that makes a worktree a retention
+// candidate walks every split pane, so the exemption must too (see
+// isEvictionExemptTerminalTab).
+export function isEvictionExemptTerminalPty(
+ ptyId: string | null | undefined,
+ worktreeId: string
+): boolean {
+ if (!ptyId || isRemoteRuntimePtyId(ptyId) || parseAppSshPtyId(ptyId)) {
+ return false
+ }
+ return !isSnapshotBackedTerminalPty(ptyId, worktreeId)
+}
+
+export type TerminalWorktreeRetentionCandidate = {
+ worktreeId: string
+ hiddenSinceMs: number | null
+ isVisible: boolean
+ shouldMeasureHiddenWorktree: boolean
+ hasActivityTerminalPortal: boolean
+ /** Post-measure cool-down (see TerminalWorktreeColdParkCandidate): force-park
+ * must not re-engage right after a measure window ends, but hiddenSince —
+ * and with it the TTL/ranking clock — stays untouched. */
+ parkCooldownUntilMs?: number | null
+ /** Ordinary cold parking can evict this worktree (park-eligible AND watcher-coverable) — the warm cap bounds it already. */
+ ordinaryParkingCovers: boolean
+ /** Pending startup or activation spawn — a mount is imminent; never evict. */
+ hasPendingSpawnWork: boolean
+}
+
+/**
+ * Retention budget over the worktrees ordinary parking can never evict: any
+ * hidden un-parkable worktree beyond the retention limit or TTL force-parks —
+ * panes unmount, watchers cover the tabs whose transport exists, and reveal
+ * restores per pty class (the app-restart experience). Eviction-exempt tabs
+ * do NOT veto the worktree: they keep their mounted panes via the per-tab
+ * exclusion (Activity-portal pattern) while sibling tabs unmount, so one
+ * exempt tab can no longer pin co-located remote-runtime tabs forever.
+ * Ranking reuses the hot-retain machinery, so deterministic ties hold here too,
+ * and the verdict changes only at deadlines or on real state transitions (no
+ * new flip-loop inputs). The last-active exemption it carries spares one
+ * worktree from the CAP only — the TTL below overrides it, else a lone hidden
+ * un-parkable worktree would stay mounted for the whole session.
+ */
+export function selectRetentionForceParkedTerminalWorktrees(
+ args: {
+ worktrees: readonly TerminalWorktreeRetentionCandidate[]
+ parkingEnabled: boolean
+ retentionBudgetEnabled: boolean
+ nowMs: number
+ } & TerminalColdParkPolicyOverrides
+): Set {
+ if (!args.parkingEnabled || !args.retentionBudgetEnabled) {
+ return new Set()
+ }
+ const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS
+ const candidates: ColdParkRetainCandidate[] = []
+ for (const worktree of args.worktrees) {
+ if (
+ worktree.hiddenSinceMs === null ||
+ worktree.isVisible ||
+ worktree.shouldMeasureHiddenWorktree ||
+ worktree.hasActivityTerminalPortal ||
+ worktree.ordinaryParkingCovers ||
+ worktree.hasPendingSpawnWork ||
+ (worktree.parkCooldownUntilMs != null && args.nowMs < worktree.parkCooldownUntilMs) ||
+ args.nowMs - worktree.hiddenSinceMs < coldParkDelayMs
+ ) {
+ continue
+ }
+ candidates.push({ id: worktree.worktreeId, hiddenSinceMs: worktree.hiddenSinceMs })
+ }
+ const retentionTtlMs = args.retentionTtlMs ?? TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS
+ const forceParkedIds = selectIdsBeyondHotRetain(candidates, {
+ nowMs: args.nowMs,
+ hotRetainMs: retentionTtlMs,
+ hotRetainLimit: args.retentionLimit ?? TERMINAL_HIDDEN_WORKTREE_RETENTION_LIMIT
+ })
+ // Why re-applied here: selectIdsBeyondHotRetain spares the last-active id from
+ // its clock too, which is right for the warm cap (instant return after a
+ // meeting) but makes "none past 45 minutes" false for a lone hidden worktree.
+ for (const candidate of candidates) {
+ if (args.nowMs - candidate.hiddenSinceMs >= retentionTtlMs) {
+ forceParkedIds.add(candidate.id)
+ }
+ }
+ return forceParkedIds
+}
+
+// Why exported: an all-exempt force-park frees nothing, and that degenerate
+// case is only observable if the empty selection is a value the host can test.
+export function selectForceParkEvictableTabIds(
+ tabs: readonly T[],
+ isExempt: (tab: T) => boolean
+): string[] {
+ return tabs.filter((tab) => !isExempt(tab)).map((tab) => tab.id)
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts
index bd044d1578f..5242ec9d94e 100644
--- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts
@@ -79,6 +79,7 @@ type MockStoreState = {
}
>
runtimePaneTitlesByTabId: Record>
+ settings: { terminalSshViewParking?: boolean } | null
clearTabLaunchAgent: ReturnType
clearRuntimePaneTitle: ReturnType
setTabLayout: ReturnType
@@ -91,6 +92,10 @@ vi.mock('@/store', () => ({
useAppStore: { getState: () => mockStoreState }
}))
+import {
+ isEvictionExemptTerminalTab,
+ selectEvictionExemptTerminalTabIds
+} from './terminal-eviction-exempt-tabs'
import {
canWatcherCoverParkedTerminalTab,
captureParkedTerminalPaneCandidates,
@@ -135,6 +140,7 @@ describe('terminal-parked-tab-watchers', () => {
tabsByWorktree: {},
terminalLayoutsByTabId: {},
runtimePaneTitlesByTabId: {},
+ settings: null,
clearTabLaunchAgent: vi.fn(),
clearRuntimePaneTitle: vi.fn(),
setTabLayout: vi.fn(),
@@ -204,10 +210,9 @@ describe('terminal-parked-tab-watchers', () => {
expect(startedWatchers[0].options).toMatchObject({ ptyId: SECOND_PTY_ID })
})
- it('never starts watchers for remote-runtime or SSH PTYs', () => {
+ it('never starts watchers for remote-runtime PTYs', () => {
capturePanes([
- { ptyId: 'remote:env-1@@terminal-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
- { ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
+ { ptyId: 'remote:env-1@@terminal-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }
])
syncParked({ tabs: [{ id: TAB_ID, ptyId: null }] })
@@ -217,6 +222,22 @@ describe('terminal-parked-tab-watchers', () => {
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
})
+ it('starts watchers for SSH PTYs (C1 SSH parking, default on)', () => {
+ capturePanes([{ ptyId: 'ssh:conn-1@@pty-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
+ syncParked({ tabs: [{ id: TAB_ID, ptyId: 'ssh:conn-1@@pty-1' }] })
+
+ expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1)
+ expect(startedWatchers[0].options).toMatchObject({ ptyId: 'ssh:conn-1@@pty-1' })
+ })
+
+ it('never starts watchers for SSH PTYs when terminalSshViewParking is off', () => {
+ mockStoreState.settings = { terminalSshViewParking: false }
+ capturePanes([{ ptyId: 'ssh:conn-1@@pty-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
+ syncParked({ tabs: [{ id: TAB_ID, ptyId: null }] })
+
+ expect(startParkedTerminalByteWatcher).not.toHaveBeenCalled()
+ })
+
it('keeps existing watchers across repeated syncs of the same parked state', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
@@ -702,6 +723,28 @@ describe('terminal-parked-tab-watchers', () => {
})
it('rejects a capture containing a PTY without snapshot backing', () => {
+ capturePanes([
+ { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
+ // Why foreign-worktree id: minted under another worktree, never restorable.
+ { ptyId: 'other::wt@@session-9', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
+ ])
+ expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
+ false
+ )
+ })
+
+ it('accepts an SSH PTY under the default C1 SSH-parking policy', () => {
+ capturePanes([
+ { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
+ { ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
+ ])
+ expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
+ true
+ )
+ })
+
+ it('rejects an SSH PTY when terminalSshViewParking is off', () => {
+ mockStoreState.settings = { terminalSshViewParking: false }
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
@@ -741,6 +784,81 @@ describe('terminal-parked-tab-watchers', () => {
)
})
})
+
+ describe('isEvictionExemptTerminalTab', () => {
+ // Why these pair with coverage: the same split tab that fails coverage (so
+ // force-park targets its worktree) must be exempt, or force-park unmounts
+ // the very live pty the exemption exists to protect.
+ it('exempts a split tab whose SECOND pane holds the unrestorable pty', () => {
+ capturePanes([
+ { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
+ { ptyId: 'other::wt@@session-9', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
+ ])
+ const tab = { id: TAB_ID, ptyId: PTY_ID }
+ expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, tab)).toBe(false)
+ expect(isEvictionExemptTerminalTab(tab, WORKTREE_ID)).toBe(true)
+ })
+
+ // Why: locks the documented residual — detection is per pane, retention is
+ // per tab, so the snapshot-backed first leaf is pinned by its fail-open
+ // sibling instead of parking on its own.
+ it('exempts a split tab even when its other leaf is snapshot-backed', () => {
+ capturePanes([
+ { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
+ // Why separator-less: the daemon-fail-open class, restorable by nothing.
+ { ptyId: 'pty-local-detached', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
+ ])
+ expect(isEvictionExemptTerminalTab({ id: TAB_ID, ptyId: PTY_ID }, WORKTREE_ID)).toBe(true)
+ })
+
+ it('exempts a split tab whose second leaf pty comes from the layout fallback', () => {
+ mockStoreState.terminalLayoutsByTabId[TAB_ID] = {
+ root: {
+ type: 'split',
+ direction: 'row',
+ first: { type: 'leaf', leafId: LEAF_ID },
+ second: { type: 'leaf', leafId: SECOND_LEAF_ID }
+ },
+ activeLeafId: LEAF_ID,
+ expandedLeafId: null,
+ ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: 'pty-local-detached' }
+ }
+ expect(isEvictionExemptTerminalTab({ id: TAB_ID, ptyId: PTY_ID }, WORKTREE_ID)).toBe(true)
+ })
+
+ it('does not exempt a split tab whose panes are all snapshot-backed', () => {
+ capturePanes([
+ { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
+ { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
+ ])
+ expect(isEvictionExemptTerminalTab({ id: TAB_ID, ptyId: PTY_ID }, WORKTREE_ID)).toBe(false)
+ })
+
+ it('exempts on tab.ptyId alone when no panes resolve', () => {
+ expect(
+ isEvictionExemptTerminalTab({ id: TAB_ID, ptyId: 'pty-local-detached' }, WORKTREE_ID)
+ ).toBe(true)
+ })
+
+ it('never exempts remote-runtime or SSH panes', () => {
+ capturePanes([
+ { ptyId: 'remote:env-1@@t-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
+ { ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
+ ])
+ expect(isEvictionExemptTerminalTab({ id: TAB_ID, ptyId: null }, WORKTREE_ID)).toBe(false)
+ })
+ })
+
+ describe('selectEvictionExemptTerminalTabIds', () => {
+ it('collects only the exempt tabs of one worktree in a single pass', () => {
+ expect(
+ selectEvictionExemptTerminalTabIds(WORKTREE_ID, [
+ { id: TAB_ID, ptyId: 'pty-local-detached' },
+ { id: 'tab-restorable', ptyId: PTY_ID }
+ ])
+ ).toEqual(new Set([TAB_ID]))
+ })
+ })
})
describe('fallbackParkedPaneCandidates', () => {
diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts
index 4328f5db980..0078705e83f 100644
--- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts
@@ -17,7 +17,10 @@ import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach'
import { subscribeToPtyExit } from './pty-dispatcher'
import { discardPreHandlerPtyState } from './pty-pre-handler-buffer'
import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher'
-import { isSnapshotBackedTerminalPty } from './terminal-hidden-view-parking'
+import {
+ isParkRestorableTerminalPty,
+ type TerminalParkRestorePolicy
+} from './terminal-hidden-view-parking'
import {
resolveTabTitleAfterPaneClose,
shouldClearLaunchAgentForClosedPane
@@ -81,7 +84,7 @@ export function fallbackParkedPaneCandidates(
}
// Why: start path and eligibility check must resolve identical candidates, or a tab passes the check then starts uncoverable.
-function resolveParkedTerminalPaneCandidates(
+export function resolveParkedTerminalPaneCandidates(
tab: ParkableTerminalTabModel,
state: ParkedPaneFallbackState
): ParkedTerminalPaneCapture[] {
@@ -94,9 +97,18 @@ function resolveParkedTerminalPaneCandidates(
return capturedIsCurrent ? captured.panes : fallbackParkedPaneCandidates(tab, state)
}
+// Why: fact-mode watchers work for any pty whose bytes transit local main —
+// SSH included — so watcher coverage follows the park-restore policy, not the
+// stricter daemon-snapshot predicate.
+function parkRestorePolicyFromState(state: {
+ settings: { terminalSshViewParking?: boolean } | null
+}): TerminalParkRestorePolicy {
+ return { sshParkingEnabled: state.settings?.terminalSshViewParking !== false }
+}
+
/**
* Whether parked byte watchers can fully cover this tab's PTYs (every candidate
- * has a snapshot-backed PTY on a valid leaf). Hosts must refuse to park a tab
+ * has a park-restorable PTY on a valid leaf). Hosts must refuse to park a tab
* that fails this check, or bell/title/completion side effects silently drop.
*/
export function canWatcherCoverParkedTerminalTab(
@@ -105,14 +117,16 @@ export function canWatcherCoverParkedTerminalTab(
// Why: cold activation needs stronger snapshot support (view never mounted); ordinary parking can reattach a mounted view.
isPtyEligible: ParkedTerminalPtyEligibility = allowSnapshotBackedPty
): boolean {
- const panes = resolveParkedTerminalPaneCandidates(tab, useAppStore.getState())
+ const state = useAppStore.getState()
+ const panes = resolveParkedTerminalPaneCandidates(tab, state)
+ const restorePolicy = parkRestorePolicyFromState(state)
return (
panes.length > 0 &&
panes.every(
(pane) =>
pane.ptyId !== null &&
isTerminalLeafId(pane.leafId) &&
- isSnapshotBackedTerminalPty(pane.ptyId, worktreeId) &&
+ isParkRestorableTerminalPty(pane.ptyId, worktreeId, restorePolicy) &&
isPtyEligible(pane.ptyId)
)
)
@@ -125,6 +139,7 @@ function startParkedTabWatchers(
): void {
const state = useAppStore.getState()
const panes = resolveParkedTerminalPaneCandidates(tab, state)
+ const restorePolicy = parkRestorePolicyFromState(state)
const disposersByPtyId = new Map void>()
const paneIdByPtyId = new Map()
for (const pane of panes) {
@@ -134,7 +149,7 @@ function startParkedTabWatchers(
!ptyId ||
disposersByPtyId.has(ptyId) ||
!isTerminalLeafId(pane.leafId) ||
- !isSnapshotBackedTerminalPty(ptyId, worktreeId)
+ !isParkRestorableTerminalPty(ptyId, worktreeId, restorePolicy)
) {
continue
}
diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts
index c3ec86a0888..fd34398824a 100644
--- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts
@@ -51,6 +51,16 @@ export function getParkedTerminalWatcherTabIds(): string[] {
return Array.from(parkedWatchersByTabId.keys())
}
+/**
+ * Whether this tab is parked right now — the reveal remount's own mount effect
+ * runs before the host effect that disposes the watcher (child effects first),
+ * so a pane reading this at connect time can tell a park-reveal from an
+ * in-place reattach. Empty entries are pinned-close tombstones, not live parks.
+ */
+export function isTerminalTabParked(tabId: string): boolean {
+ return (parkedWatchersByTabId.get(tabId)?.disposersByPtyId.size ?? 0) > 0
+}
+
export function disposeParkedTabWatchers(tabId: string): void {
const entry = parkedWatchersByTabId.get(tabId)
if (!entry) {
diff --git a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts
index 40e226cf5df..b15b8712b45 100644
--- a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts
@@ -1,6 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-type MockE2EConfig = { exposeStore: boolean; terminalParkingDelayMs: number | null }
+type MockE2EConfig = {
+ exposeStore: boolean
+ terminalParkingDelayMs: number | null
+ terminalRetentionLimit: number | null
+}
let mockE2EConfig: MockE2EConfig
@@ -21,6 +25,8 @@ type TerminalParkingE2EOverridesModule = {
coldParkDelayMs?: number
hotRetainMs?: number
hotRetainLimit?: number
+ retentionTtlMs?: number
+ retentionLimit?: number
}
registerTerminalParkingDebugHandle: () => void
}
@@ -32,7 +38,11 @@ async function importOverridesModule(): Promise {
beforeEach(() => {
- mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null }
+ mockE2EConfig = {
+ exposeStore: false,
+ terminalParkingDelayMs: null,
+ terminalRetentionLimit: null
+ }
delete (globalThis as { window?: unknown }).window
})
@@ -41,13 +51,17 @@ describe('getTerminalParkingPolicyOverrides', () => {
})
it('ignores the delay override outside e2e (exposeStore off)', async () => {
- mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: 500 }
+ mockE2EConfig = {
+ exposeStore: false,
+ terminalParkingDelayMs: 500,
+ terminalRetentionLimit: null
+ }
const { getTerminalParkingPolicyOverrides } = await importOverridesModule()
expect(getTerminalParkingPolicyOverrides()).toEqual({})
})
- it('maps the e2e delay to BOTH coldParkDelayMs and hotRetainMs', async () => {
- mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500 }
+ it('maps the e2e delay to coldParkDelayMs and hotRetainMs but never the absolute retention TTL', async () => {
+ mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500, terminalRetentionLimit: null }
const { getTerminalParkingPolicyOverrides } = await importOverridesModule()
expect(getTerminalParkingPolicyOverrides()).toEqual({
coldParkDelayMs: 500,
@@ -56,13 +70,47 @@ describe('getTerminalParkingPolicyOverrides', () => {
})
it('returns no overrides when no delay is configured', async () => {
- mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: null }
+ mockE2EConfig = {
+ exposeStore: true,
+ terminalParkingDelayMs: null,
+ terminalRetentionLimit: null
+ }
const { getTerminalParkingPolicyOverrides } = await importOverridesModule()
expect(getTerminalParkingPolicyOverrides()).toEqual({})
})
+ it('maps the e2e retention limit independently of the delay', async () => {
+ mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: null, terminalRetentionLimit: 1 }
+ const { getTerminalParkingPolicyOverrides } = await importOverridesModule()
+ expect(getTerminalParkingPolicyOverrides()).toEqual({ retentionLimit: 1 })
+ })
+
+ it('combines the delay overrides with the retention limit when both are set', async () => {
+ mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500, terminalRetentionLimit: 1 }
+ const { getTerminalParkingPolicyOverrides } = await importOverridesModule()
+ expect(getTerminalParkingPolicyOverrides()).toEqual({
+ coldParkDelayMs: 500,
+ hotRetainMs: 500,
+ retentionLimit: 1
+ })
+ })
+
+ it('ignores the retention limit outside e2e (exposeStore off)', async () => {
+ mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null, terminalRetentionLimit: 1 }
+ const { getTerminalParkingPolicyOverrides } = await importOverridesModule()
+ expect(getTerminalParkingPolicyOverrides()).toEqual({})
+ })
+
+ it('rejects non-integer or non-positive retention limits', async () => {
+ mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: null, terminalRetentionLimit: 1.5 }
+ const overridesModule = await importOverridesModule()
+ expect(overridesModule.getTerminalParkingPolicyOverrides()).toEqual({})
+ mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: null, terminalRetentionLimit: 0 }
+ expect(overridesModule.getTerminalParkingPolicyOverrides()).toEqual({})
+ })
+
it('registers window.__terminalParkingDebug on import under exposeStore', async () => {
- mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500 }
+ mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500, terminalRetentionLimit: null }
const testWindow: {
__terminalParkingDebug?: { parkDelayMs: number; parkedTabIds: () => string[] }
} = {}
@@ -73,7 +121,11 @@ describe('getTerminalParkingPolicyOverrides', () => {
})
it('does not register the debug handle outside e2e', async () => {
- mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null }
+ mockE2EConfig = {
+ exposeStore: false,
+ terminalParkingDelayMs: null,
+ terminalRetentionLimit: null
+ }
const testWindow: { __terminalParkingDebug?: unknown } = {}
;(globalThis as { window?: unknown }).window = testWindow
await importOverridesModule()
diff --git a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts
index 1717b170bb9..cbef3cebf5e 100644
--- a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts
@@ -10,10 +10,23 @@ import { getParkedTerminalWatcherTabIds } from './terminal-parked-tab-watchers'
// in the hot-retain working set for 5 minutes and never park within a test
// run. Gated on exposeStore so packaged builds ignore stray env vars.
export function getTerminalParkingPolicyOverrides(): TerminalColdParkPolicyOverrides {
- const delayMs = e2eConfig.exposeStore ? e2eConfig.terminalParkingDelayMs : null
- return typeof delayMs === 'number' && Number.isFinite(delayMs) && delayMs > 0
- ? { coldParkDelayMs: delayMs, hotRetainMs: delayMs }
- : {}
+ if (!e2eConfig.exposeStore) {
+ return {}
+ }
+ const delayMs = e2eConfig.terminalParkingDelayMs
+ const retentionLimit = e2eConfig.terminalRetentionLimit
+ return {
+ // Why the retention TTL keeps production timing: it is absolute (the
+ // last-active exemption does not spare it), so shrinking it here would
+ // evict the newest hidden worktree the cap specs assert stays mounted.
+ ...(typeof delayMs === 'number' && Number.isFinite(delayMs) && delayMs > 0
+ ? { coldParkDelayMs: delayMs, hotRetainMs: delayMs }
+ : {}),
+ // Why: limit=1 lets a spec force-park with only two hidden un-parkable worktrees (production floor is 12).
+ ...(typeof retentionLimit === 'number' && Number.isInteger(retentionLimit) && retentionLimit > 0
+ ? { retentionLimit }
+ : {})
+ }
}
export function registerTerminalParkingDebugHandle(): void {
diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts
index 7acbd6a5ff9..dcbf3782f4b 100644
--- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts
@@ -219,6 +219,78 @@ describe('captureTerminalShutdownLayout', () => {
expect(buffer).toHaveLength(fittingRows * 1024 + CURSOR_HOME.length)
})
+ it('bounds serialize probes while capping an oversized buffer by UTF-8 bytes', async () => {
+ const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture')
+ const multibyteRow = 'é'.repeat(1024)
+ const serialize = vi.fn((options?: { scrollback?: number }) =>
+ multibyteRow.repeat(options?.scrollback ?? 0)
+ )
+ const pane = {
+ id: 1,
+ leafId: LEAF_ID,
+ stablePaneId: LEAF_ID,
+ terminal: mockTerminal(512),
+ serializeAddon: { serialize }
+ }
+ const manager = {
+ getPanes: vi.fn(() => [pane]),
+ getActivePane: vi.fn(() => pane)
+ }
+
+ const layout = captureTerminalShutdownLayout({
+ manager: manager as never,
+ container: mockRootForPane(1),
+ expandedPaneId: null,
+ paneTransports: new Map([[1, { getPtyId: vi.fn(() => 'pty-1') }]]),
+ paneTitlesByPaneId: { 1: 'ssh shell' },
+ existingLayout: undefined
+ })
+
+ // Full pass plus at most MAX_SCROLLBACK_FIT_PROBES fit probes; a bisection took 10+.
+ expect(serialize.mock.calls.length).toBeLessThanOrEqual(5)
+ expect(getUtf8ByteLength(layout.buffersByLeafId?.[LEAF_ID] ?? '')).toBeLessThanOrEqual(
+ TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT
+ )
+ })
+
+ it('keeps most of the byte budget when dense recent rows sit above sparse scrollback', async () => {
+ const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture')
+ // Why this shape: the common terminal profile (a long quiet prompt history under a dense
+ // build/agent run) makes bytes-per-row wildly non-uniform, which a pure secant step creeps on.
+ const DENSE_ROWS = 1_000
+ const DENSE_ROW = 'x'.repeat(600)
+ const serialize = vi.fn((options?: { scrollback?: number }) => {
+ const rows = options?.scrollback ?? 0
+ const dense = Math.min(rows, DENSE_ROWS)
+ return '.'.repeat(Math.max(rows - DENSE_ROWS, 0)) + DENSE_ROW.repeat(dense)
+ })
+ const pane = {
+ id: 1,
+ leafId: LEAF_ID,
+ stablePaneId: LEAF_ID,
+ terminal: mockTerminal(5_000),
+ serializeAddon: { serialize }
+ }
+ const manager = {
+ getPanes: vi.fn(() => [pane]),
+ getActivePane: vi.fn(() => pane)
+ }
+
+ const layout = captureTerminalShutdownLayout({
+ manager: manager as never,
+ container: mockRootForPane(1),
+ expandedPaneId: null,
+ paneTransports: new Map([[1, { getPtyId: vi.fn(() => 'pty-1') }]]),
+ paneTitlesByPaneId: { 1: 'ssh shell' },
+ existingLayout: undefined
+ })
+
+ const bytes = getUtf8ByteLength(layout.buffersByLeafId?.[LEAF_ID] ?? '')
+ expect(bytes).toBeLessThanOrEqual(TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT)
+ expect(bytes).toBeGreaterThan(TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT * 0.9)
+ expect(serialize.mock.calls.length).toBeLessThanOrEqual(5)
+ })
+
it('does not preserve prior scrollback buffers or refs for a cleared leaf', async () => {
const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture')
const pane = {
diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts
index 4e6d1698787..006a76b913f 100644
--- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts
@@ -7,7 +7,7 @@ import { mergeCapturedLeafState } from './merge-captured-leaf-state'
import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids'
import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits'
import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor'
-import { measureUtf8ByteLength } from '../../../../shared/utf8-byte-limits'
+import { getUtf8ByteLength, measureUtf8ByteLength } from '../../../../shared/utf8-byte-limits'
const MAX_BUFFER_BYTES = TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT
@@ -46,6 +46,49 @@ function fitsSessionScrollbackByteLimit(serialized: string): boolean {
return !measureUtf8ByteLength(serialized, { stopAfterBytes: MAX_BUFFER_BYTES }).exceededLimit
}
+// Why bounded: a plain row bisection costs ~13 full serializes per over-limit pane (~250ms at the
+// 5k scrollback default, ~700ms at 50k), and force-park pays it synchronously per evicted pane.
+const MAX_SCROLLBACK_FIT_PROBES = 4
+
+/** Largest tail of `pane` that fits the session byte cap, found in a few interpolation probes. */
+function serializeWithinSessionScrollbackByteLimit(
+ pane: ShutdownPane,
+ oversized: string,
+ scrollback: number
+): string {
+ let overRows = scrollback
+ let overBytes = getUtf8ByteLength(oversized)
+ let fitRows = 0
+ let fitBytes = 0
+ let best: string | null = null
+ // Why the extra probes before the first fit: returning '' would drop the whole pane, and those
+ // probes shrink geometrically, so they cost less in total than a bisection's upper-half passes.
+ for (let probe = 0; probe < MAX_SCROLLBACK_FIT_PROBES || best === null; probe += 1) {
+ const anchorRows = best === null ? overRows : fitRows
+ const anchorBytes = best === null ? overBytes : fitBytes
+ // Bytes grow ~linearly with rows, so a secant step lands on the exact fit for uniform
+ // scrollback; the midpoint floor stops it creeping when dense recent rows sit above sparse old ones.
+ const estimate = Math.floor((anchorRows * MAX_BUFFER_BYTES) / Math.max(anchorBytes, 1))
+ const midpoint = Math.floor((fitRows + overRows) / 2)
+ const rows = Math.min(Math.max(Math.min(estimate, midpoint), fitRows + 1), overRows - 1)
+ if (rows <= fitRows || rows >= overRows) {
+ break
+ }
+ const attempt = serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, {
+ scrollback: rows
+ })
+ if (fitsSessionScrollbackByteLimit(attempt)) {
+ best = attempt
+ fitRows = rows
+ fitBytes = getUtf8ByteLength(attempt)
+ } else {
+ overRows = rows
+ overBytes = getUtf8ByteLength(attempt)
+ }
+ }
+ return best ?? ''
+}
+
export function captureTerminalShutdownLayout({
manager,
container,
@@ -76,22 +119,7 @@ export function captureTerminalShutdownLayout({
// Why: SSH sleep keeps this string in session JSON; cap by UTF-8
// bytes so non-ASCII scrollback cannot bypass the intended bound.
if (!fitsSessionScrollbackByteLimit(serialized) && scrollback > 1) {
- let lo = 1
- let hi = scrollback
- let best = ''
- while (lo <= hi) {
- const mid = Math.floor((lo + hi) / 2)
- const attempt = serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, {
- scrollback: mid
- })
- if (fitsSessionScrollbackByteLimit(attempt)) {
- best = attempt
- lo = mid + 1
- } else {
- hi = mid - 1
- }
- }
- serialized = best
+ serialized = serializeWithinSessionScrollbackByteLimit(pane, serialized, scrollback)
}
if (serialized.length > 0) {
buffers[leafId] = serialized
diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts
index 84a91c2e7c4..c5f2b696f53 100644
--- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts
@@ -429,6 +429,136 @@ describe('registerTerminalSideEffectFactConsumer', () => {
expect(events).toEqual([])
})
+ it('hands off facts emitted between the parked watcher unregistering and the pane registering', () => {
+ // Why: the reveal window — the watcher unregisters synchronously in the
+ // effect flush, the pane registers only after its async reattach resolves,
+ // and replay is title-only, so an unbuffered bell/command-code-done is lost.
+ const watcher = createCallbackRecorder()
+ const disposeWatcher = registerTerminalSideEffectFactConsumer({
+ ptyId: PTY_ID,
+ callbacks: watcher.callbacks
+ })
+ disposeWatcher()
+
+ const events: unknown[][] = []
+ _dispatchTerminalSideEffectBatchForTest(
+ batch([{ kind: 'bell' }, { kind: 'command-code-done', prompt: 'Fix the spinner' }], {
+ seq: 7
+ })
+ )
+ // A PTY that never had a consumer stays dropped — no buffer was opened.
+ _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }], { ptyId: 'never-consumed' }))
+
+ expect(events).toEqual([])
+ expect(watcher.events).toEqual([])
+
+ const neverConsumed = createCallbackRecorder()
+ registerTerminalSideEffectFactConsumer({
+ ptyId: 'never-consumed',
+ callbacks: neverConsumed.callbacks
+ })
+ expect(neverConsumed.events).toEqual([])
+
+ const dispose = registerTerminalSideEffectFactConsumer({
+ ptyId: PTY_ID,
+ callbacks: {
+ onBell: () => events.push(['bell']),
+ onCommandCodeDone: (prompt) => events.push(['cc-done', prompt])
+ }
+ })
+
+ expect(events).toEqual([['bell'], ['cc-done', 'Fix the spinner']])
+ expect(watcher.events).toEqual([])
+
+ // Drained exactly once: a later registration gets nothing.
+ dispose()
+ const later: unknown[][] = []
+ registerTerminalSideEffectFactConsumer({
+ ptyId: PTY_ID,
+ callbacks: {
+ onBell: () => later.push(['bell']),
+ onCommandCodeDone: (prompt) => later.push(['cc-done', prompt])
+ }
+ })
+
+ expect(later).toEqual([])
+ })
+
+ it('does not buffer replay batches across a handoff', () => {
+ // Why: the next consumer requests its own snapshot; a held replay would
+ // regress the title it just restored.
+ const dispose = registerTerminalSideEffectFactConsumer({
+ ptyId: PTY_ID,
+ callbacks: createCallbackRecorder().callbacks
+ })
+ dispose()
+
+ _dispatchTerminalSideEffectBatchForTest(
+ batch([{ kind: 'title', normalizedTitle: 'stale', rawTitle: 'stale' }], {
+ replay: true,
+ seq: 5
+ })
+ )
+
+ const { callbacks, events } = createCallbackRecorder()
+ registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
+
+ expect(events).toEqual([])
+ })
+
+ it('drains the handoff buffer before the register snapshot, so the replay is stale', async () => {
+ // Why: the drained live title sets lastLiveTitleSeq, which is what makes
+ // the async snapshot's older title lose to the fact the pane already got.
+ const snapshot = batch([{ kind: 'title', normalizedTitle: 'snapshot', rawTitle: 'snapshot' }], {
+ replay: true,
+ seq: 20
+ })
+ ;(globalThis as { window: unknown }).window = {
+ api: { pty: { getSideEffectSnapshot: vi.fn(async () => snapshot) } }
+ }
+
+ const dispose = registerTerminalSideEffectFactConsumer({
+ ptyId: PTY_ID,
+ callbacks: createCallbackRecorder().callbacks
+ })
+ dispose()
+ _dispatchTerminalSideEffectBatchForTest(
+ batch([{ kind: 'title', normalizedTitle: 'live', rawTitle: 'live' }], { seq: 20 })
+ )
+
+ const { callbacks, events } = createCallbackRecorder()
+ registerTerminalSideEffectFactConsumer({
+ ptyId: PTY_ID,
+ callbacks,
+ restoreTitleOnRegister: true
+ })
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(events).toEqual([['title', 'live', 'live']])
+ })
+
+ it('drops a handoff-buffered batch once the buffer TTL expires', () => {
+ vi.useFakeTimers()
+ try {
+ const dispose = registerTerminalSideEffectFactConsumer({
+ ptyId: PTY_ID,
+ callbacks: createCallbackRecorder().callbacks
+ })
+ dispose()
+ _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }]))
+
+ vi.advanceTimersByTime(15_001)
+
+ const { callbacks, events } = createCallbackRecorder()
+ registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
+
+ expect(events).toEqual([])
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
it('subscribes to the channel once and routes IPC batches', () => {
let channelCallback: ((batch: TerminalSideEffectBatch) => void) | null = null
const onSideEffect = vi.fn((callback: (batch: TerminalSideEffectBatch) => void) => {
diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts
index 892e8977d72..95374ae9f8a 100644
--- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts
@@ -9,7 +9,8 @@
* callbacks), so every fact has exactly one policy consumer regardless of
* whether the tab is mounted, hidden, or parked. Facts for PTYs without a
* registered consumer are dropped — mirroring today's eager-buffer behavior
- * where pre-mount output produces no attention side effects.
+ * where pre-mount output produces no attention side effects. The one exception
+ * is a PTY whose consumer just unregistered: see the handoff buffer below.
*/
import type { GlobalSettings } from '../../../../shared/types'
import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector'
@@ -166,9 +167,78 @@ function applyBatchToConsumer(entry: ConsumerEntry, batch: TerminalSideEffectBat
}
}
+// Why: a reveal remount unregisters the parked watcher synchronously, but the
+// replacement pane registers only after its deferred rAF + async reattach
+// resolves — and replay is title-only, so a bell or a command-code 'done'
+// dropped in that window is lost for good. Only a PTY that just lost its
+// consumer buffers (never-consumed PTYs still drop, per the module contract),
+// bounded in time, batches, and PTYs so an abandoned handoff retains nothing.
+const HANDOFF_FACT_BUFFER_TTL_MS = 15_000
+const MAX_HANDOFF_FACT_BATCHES = 64
+const MAX_HANDOFF_FACT_PTYS = 32
+
+type HandoffFactBuffer = { batches: TerminalSideEffectBatch[]; expiresAtMs: number }
+const handoffFactBuffersByPtyId = new Map()
+
+function openHandoffFactBuffer(ptyId: string): void {
+ const nowMs = Date.now()
+ for (const [bufferedPtyId, buffer] of handoffFactBuffersByPtyId) {
+ if (buffer.expiresAtMs <= nowMs) {
+ handoffFactBuffersByPtyId.delete(bufferedPtyId)
+ }
+ }
+ if (
+ !handoffFactBuffersByPtyId.has(ptyId) &&
+ handoffFactBuffersByPtyId.size >= MAX_HANDOFF_FACT_PTYS
+ ) {
+ const oldestPtyId = handoffFactBuffersByPtyId.keys().next().value
+ if (typeof oldestPtyId === 'string') {
+ handoffFactBuffersByPtyId.delete(oldestPtyId)
+ }
+ }
+ handoffFactBuffersByPtyId.set(ptyId, {
+ batches: [],
+ expiresAtMs: nowMs + HANDOFF_FACT_BUFFER_TTL_MS
+ })
+}
+
+function bufferHandoffFactBatch(batch: TerminalSideEffectBatch): void {
+ const buffer = handoffFactBuffersByPtyId.get(batch.ptyId)
+ if (!buffer) {
+ return
+ }
+ if (buffer.expiresAtMs <= Date.now()) {
+ handoffFactBuffersByPtyId.delete(batch.ptyId)
+ return
+ }
+ // Why: replay batches are snapshots the next consumer requests for itself.
+ if (batch.replay) {
+ return
+ }
+ if (buffer.batches.length >= MAX_HANDOFF_FACT_BATCHES) {
+ buffer.batches.shift()
+ }
+ buffer.batches.push(batch)
+}
+
+function drainHandoffFactBuffer(ptyId: string, entry: ConsumerEntry): void {
+ const buffer = handoffFactBuffersByPtyId.get(ptyId)
+ if (!buffer) {
+ return
+ }
+ handoffFactBuffersByPtyId.delete(ptyId)
+ if (buffer.expiresAtMs <= Date.now()) {
+ return
+ }
+ for (const batch of buffer.batches) {
+ applyBatchToConsumer(entry, batch)
+ }
+}
+
function handleSideEffectBatch(batch: TerminalSideEffectBatch): void {
const entry = consumersByPtyId.get(batch.ptyId)
if (!entry) {
+ bufferHandoffFactBatch(batch)
return
}
applyBatchToConsumer(entry, batch)
@@ -211,6 +281,8 @@ export function registerTerminalSideEffectFactConsumer(
lastLiveTitleSeq: null
}
consumersByPtyId.set(options.ptyId, entry)
+ // Why before the snapshot request: draining live facts sets lastLiveTitleSeq, so the async title replay is correctly dropped as stale.
+ drainHandoffFactBuffer(options.ptyId, entry)
if (options.restoreTitleOnRegister) {
const getSnapshot = (globalThis as { window?: Window }).window?.api?.pty?.getSideEffectSnapshot
@@ -230,6 +302,8 @@ export function registerTerminalSideEffectFactConsumer(
return () => {
if (consumersByPtyId.get(options.ptyId) === entry) {
consumersByPtyId.delete(options.ptyId)
+ // Why: the pane that replaces this consumer registers only after its async reattach resolves; hold facts across that handoff.
+ openHandoffFactBuffer(options.ptyId)
}
}
}
@@ -242,6 +316,7 @@ export function _dispatchTerminalSideEffectBatchForTest(batch: TerminalSideEffec
/** Test seam: reset module state between tests. */
export function _resetTerminalSideEffectFactConsumersForTest(): void {
consumersByPtyId.clear()
+ handoffFactBuffersByPtyId.clear()
channelUnsubscribe?.()
channelUnsubscribe = null
persistedAuthorityFlagCache = undefined
diff --git a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts
new file mode 100644
index 00000000000..ccae12ee863
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest'
+import {
+ buildMainModelSnapshotReplayWrites,
+ hasPositiveTerminalDimensions,
+ resolvePositiveTerminalDimensions
+} from './terminal-snapshot-replay-paint'
+
+describe('hasPositiveTerminalDimensions', () => {
+ it('accepts only finite positive numeric pairs', () => {
+ expect(hasPositiveTerminalDimensions(80, 24)).toBe(true)
+ expect(hasPositiveTerminalDimensions(1, 1)).toBe(true)
+ })
+
+ // Why: Infinity passes `> 0` — the exact drift that let a malformed SSH
+ // model snapshot reach terminal.resize(Infinity, …).
+ it('rejects non-finite, non-positive, and non-numeric values', () => {
+ expect(hasPositiveTerminalDimensions(Infinity, 24)).toBe(false)
+ expect(hasPositiveTerminalDimensions(80, Infinity)).toBe(false)
+ expect(hasPositiveTerminalDimensions(Number.NaN, 24)).toBe(false)
+ expect(hasPositiveTerminalDimensions(0, 24)).toBe(false)
+ expect(hasPositiveTerminalDimensions(80, -1)).toBe(false)
+ expect(hasPositiveTerminalDimensions(undefined, 24)).toBe(false)
+ expect(hasPositiveTerminalDimensions('80', 24)).toBe(false)
+ expect(hasPositiveTerminalDimensions(null, null)).toBe(false)
+ })
+})
+
+describe('resolvePositiveTerminalDimensions', () => {
+ it('returns the numeric pair only when valid', () => {
+ expect(resolvePositiveTerminalDimensions(80, 24)).toEqual({ cols: 80, rows: 24 })
+ expect(resolvePositiveTerminalDimensions(Infinity, 24)).toBeNull()
+ expect(resolvePositiveTerminalDimensions(undefined, undefined)).toBeNull()
+ })
+})
+
+describe('buildMainModelSnapshotReplayWrites', () => {
+ it('clears normal buffer + scrollback before a normal-buffer snapshot', () => {
+ expect(buildMainModelSnapshotReplayWrites({ data: 'shell-output' })).toEqual([
+ '\x1b[2J\x1b[3J\x1b[H',
+ 'shell-output'
+ ])
+ })
+
+ // Why: main strips the ?1049h marker when splitting scrollbackAnsi from an
+ // alt frame, so the restorer must own the ?1049l rebuild + ?1049h return —
+ // painting the composed bytes after a plain clear leaves the TUI frame on
+ // the normal buffer.
+ it('rebuilds normal buffer then paints a clean alt frame for alt-screen snapshots', () => {
+ expect(
+ buildMainModelSnapshotReplayWrites({
+ data: 'alt-frame',
+ alternateScreen: true,
+ scrollbackAnsi: 'normal-history'
+ })
+ ).toEqual([
+ '\x1b[?1049l\x1b[2J\x1b[3J\x1b[H',
+ 'normal-history',
+ '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H',
+ 'alt-frame'
+ ])
+ })
+
+ it('enters a cleared alt screen when no split scrollback is available', () => {
+ expect(
+ buildMainModelSnapshotReplayWrites({ data: 'alt-frame', alternateScreen: true })
+ ).toEqual(['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', 'alt-frame'])
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts
new file mode 100644
index 00000000000..b17bdd5435a
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts
@@ -0,0 +1,62 @@
+/**
+ * Shared guards and write choreography for painting a main-model snapshot into
+ * a (possibly fresh) xterm. One source for the reattach/hidden-restore paint
+ * paths so their dimension guards and alt-screen branches cannot drift.
+ */
+
+/** True only for finite positive numeric cols/rows — Infinity/NaN/undefined
+ * from a malformed snapshot must degrade to "no resize", never reach
+ * terminal.resize(). */
+export function hasPositiveTerminalDimensions(cols: unknown, rows: unknown): boolean {
+ return (
+ typeof cols === 'number' &&
+ typeof rows === 'number' &&
+ Number.isFinite(cols) &&
+ Number.isFinite(rows) &&
+ cols > 0 &&
+ rows > 0
+ )
+}
+
+/** Narrowing form of hasPositiveTerminalDimensions for optional-typed payloads. */
+export function resolvePositiveTerminalDimensions(
+ cols: unknown,
+ rows: unknown
+): { cols: number; rows: number } | null {
+ return hasPositiveTerminalDimensions(cols, rows)
+ ? { cols: cols as number, rows: rows as number }
+ : null
+}
+
+/**
+ * Ordered replay writes for a main-model snapshot, including the alt-screen
+ * choreography: main strips the `?1049h` marker when splitting scrollbackAnsi
+ * from an alt frame, so the restorer owns the transition — rebuild the normal
+ * buffer while on it, then paint the alt frame clean. Callers write these
+ * before their post-replay reset/escape-tail sequences.
+ */
+export function buildMainModelSnapshotReplayWrites(snapshot: {
+ data: string
+ alternateScreen?: boolean
+ scrollbackAnsi?: string
+}): string[] {
+ if (!snapshot.alternateScreen) {
+ // Why: \x1b[3J wipes xterm scrollback; safe here because a normal-buffer
+ // snapshot carries its own history in data (mirrors pty-transport.ts).
+ return ['\x1b[2J\x1b[3J\x1b[H', snapshot.data]
+ }
+ if (snapshot.scrollbackAnsi !== undefined) {
+ // Why: main serializes normal + alt buffers separately; rebuild normal
+ // while active, then return to a clean alt frame.
+ return [
+ '\x1b[?1049l\x1b[2J\x1b[3J\x1b[H',
+ snapshot.scrollbackAnsi,
+ '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H',
+ snapshot.data
+ ]
+ }
+ // Why: the snapshot's ?1049h no-ops when already on alt screen and skips
+ // blank cells; clear the alt buffer so the pre-hide frame can't bleed
+ // through blank cells (spares normal-buffer scrollback).
+ return ['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', snapshot.data]
+}
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 66e91ee7fdb..d2472c01686 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
@@ -109,6 +109,7 @@ import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-str
import { getConnectionId } from '@/lib/connection-context'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard'
+import { canReleaseReplayedScrollbackFromStore } from './replayed-scrollback-store-release'
import { fitAndFocusPanes, fitPanes } from './pane-helpers'
import { markTerminalPinnedViewport } from '@/lib/pane-manager/terminal-scroll-intent'
import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll-intent-settle'
@@ -1382,12 +1383,23 @@ export function useTerminalPaneLifecycle({
replayingPanesRef,
restoredViewportBlankingPanesRef
)
- if (restoredBuffers && initialLayoutRef.current.scrollbackRefsByLeafId) {
+ const hasScrollbackRefs = Boolean(initialLayoutRef.current.scrollbackRefsByLeafId)
+ if (
+ restoredBuffers &&
+ canReleaseReplayedScrollbackFromStore({
+ hasScrollbackRefs,
+ worktreeId,
+ repos: useAppStore.getState().repos
+ })
+ ) {
const layoutWithoutRestoredBuffers = { ...initialLayoutRef.current }
delete layoutWithoutRestoredBuffers.buffersByLeafId
- initialLayoutRef.current = layoutWithoutRestoredBuffers
+ if (hasScrollbackRefs) {
+ // Why refs-only: without a disk ref this mount is the sole replay source, so a re-run (StrictMode) still needs the bytes.
+ initialLayoutRef.current = layoutWithoutRestoredBuffers
+ }
if (initialLayoutHadBuffers) {
- // Why: raw replay bytes belong to this mount only; drop legacy hydrated copies from Zustand so session writes stay ref-only.
+ // Why: xterm owns the replayed bytes now; leaving the park-time copy in Zustand retains up to 512KB/leaf for the app's lifetime.
useAppStore.getState().setTabLayout(tabId, layoutWithoutRestoredBuffers)
}
}
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts
new file mode 100644
index 00000000000..24ae41a7725
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts
@@ -0,0 +1,214 @@
+// @vitest-environment happy-dom
+import { act, renderHook } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { TerminalTab } from '../../../../shared/types'
+
+const mocks = vi.hoisted(() => ({
+ storeState: {
+ pendingStartupByTabId: {} as Record,
+ settings: {} as Record,
+ terminalLayoutsByTabId: {} as Record }>
+ },
+ exemptTabIds: new Set(),
+ exemptSelectCalls: 0
+}))
+
+vi.mock('../../store', () => ({
+ useAppStore: (selector: (state: unknown) => unknown) => selector(mocks.storeState)
+}))
+
+vi.mock('./terminal-eviction-exempt-tabs', () => ({
+ selectEvictionExemptTerminalTabIds: (_worktreeId: string, tabs: readonly { id: string }[]) => {
+ mocks.exemptSelectCalls += 1
+ return new Set(tabs.filter((tab) => mocks.exemptTabIds.has(tab.id)).map((tab) => tab.id))
+ },
+ selectEvictionExemptTerminalTabLayoutKey: (
+ state: typeof mocks.storeState,
+ tabs: readonly { id: string }[]
+ ) =>
+ tabs
+ .map(
+ (tab) => `${tab.id}=${JSON.stringify(state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId)}`
+ )
+ .join('|')
+}))
+
+vi.mock('./terminal-parked-tab-watchers', () => ({
+ canWatcherCoverParkedTerminalTab: () => true,
+ disposeParkedTerminalWatchersForWorktree: vi.fn(),
+ syncParkedTerminalTabWatchers: vi.fn()
+}))
+
+import {
+ TERMINAL_TAB_COLD_PARK_DELAY_MS,
+ TERMINAL_TAB_HOT_RETAIN_MS
+} from './terminal-hidden-view-parking'
+import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking'
+
+const WORKTREE_ID = 'wt-1'
+
+function terminalTab(id: string): TerminalTab {
+ return { id, ptyId: `${WORKTREE_ID}@@session-${id}` } as TerminalTab
+}
+
+function hookArgs(shouldMeasureHiddenWorktree: boolean) {
+ return {
+ worktreeId: WORKTREE_ID,
+ terminalTabs: [terminalTab('tab-1'), terminalTab('tab-2')],
+ assignments: new Map(),
+ isWorktreeActive: false,
+ coldParkTerminalPanes: false,
+ shouldMeasureHiddenWorktree,
+ activityTerminalPortals: [],
+ activationDeferredMountTabIds: null
+ }
+}
+
+describe('useTerminalTabColdParking measure-clock contract', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(1_000_000)
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ mocks.exemptTabIds = new Set()
+ mocks.exemptSelectCalls = 0
+ mocks.storeState.terminalLayoutsByTabId = {}
+ })
+
+ // Why: the worktree layer preserves hiddenSince through a background-measure
+ // window; the tab layer must share that contract (no clock reset) while a
+ // post-measure cool-down prevents the instant re-park thrash.
+ it('preserves tab hiddenSince through a measure window and re-parks only after the cool-down', () => {
+ const { result, rerender } = renderHook(
+ (args: ReturnType) => useTerminalTabColdParking(args),
+ { initialProps: hookArgs(false) }
+ )
+ expect(result.current.parkedTerminalTabIds.size).toBe(0)
+
+ // Past hot-retain: tab-1 holds the last-active exemption, tab-2 parks.
+ act(() => {
+ vi.advanceTimersByTime(TERMINAL_TAB_HOT_RETAIN_MS + 1)
+ })
+ expect(result.current.parkedTerminalTabIds).toEqual(new Set(['tab-2']))
+
+ // A measure window reveals every tab but must not clear the hidden clock.
+ act(() => {
+ rerender(hookArgs(true))
+ })
+ expect(result.current.parkedTerminalTabIds.size).toBe(0)
+ act(() => {
+ vi.advanceTimersByTime(3_000)
+ rerender(hookArgs(false))
+ })
+
+ // Measure just ended: the cool-down vetoes an instant re-park (the thrash).
+ expect(result.current.parkedTerminalTabIds.size).toBe(0)
+ act(() => {
+ vi.advanceTimersByTime(TERMINAL_TAB_COLD_PARK_DELAY_MS - 1)
+ })
+ expect(result.current.parkedTerminalTabIds.size).toBe(0)
+
+ // One cool-down later tab-2 re-parks — proving hiddenSince survived the
+ // measure (a cleared clock would demand a fresh 15-minute hot-retain).
+ act(() => {
+ vi.advanceTimersByTime(2)
+ })
+ expect(result.current.parkedTerminalTabIds).toEqual(new Set(['tab-2']))
+ })
+
+ // Why: a measure window also ends when the user opens the worktree; the
+ // hysteresis is then owed to nobody, so still-hidden background tabs must not
+ // sit out a cool-down (Terminal.tsx clears the worktree clock the same way).
+ it('clears the post-measure cool-down when the worktree becomes visible', () => {
+ const { result, rerender } = renderHook(
+ (args: ReturnType) => useTerminalTabColdParking(args),
+ { initialProps: hookArgs(false) }
+ )
+
+ act(() => {
+ vi.advanceTimersByTime(TERMINAL_TAB_HOT_RETAIN_MS + 1)
+ })
+ expect(result.current.parkedTerminalTabIds).toEqual(new Set(['tab-2']))
+
+ act(() => {
+ rerender(hookArgs(true))
+ })
+ expect(result.current.parkedTerminalTabIds.size).toBe(0)
+
+ // Measure ends because the worktree went visible: tab-2 is still hidden
+ // (no active group assignment) and re-parks on this very pass.
+ act(() => {
+ vi.advanceTimersByTime(3_000)
+ rerender({ ...hookArgs(false), isWorktreeActive: true })
+ })
+ expect(result.current.parkedTerminalTabIds).toEqual(new Set(['tab-2']))
+ })
+
+ // Why: force-park is the only park that can contain unrestorable ptys, so its
+ // exempt tabs keep their panes — a remount would orphan a live shell.
+ it('keeps eviction-exempt tabs mounted when the worktree is force-parked', () => {
+ mocks.exemptTabIds = new Set(['tab-1'])
+ const { result, rerender } = renderHook(
+ (args: ReturnType & { isForceParked: boolean }) =>
+ useTerminalTabColdParking(args),
+ { initialProps: { ...hookArgs(false), coldParkTerminalPanes: true, isForceParked: true } }
+ )
+ expect(result.current.parkedTerminalTabIds).toEqual(new Set(['tab-2']))
+
+ // The carve-out is scoped to force-parks: an ordinary worktree park has no
+ // exempt tabs to protect, so it evicts both.
+ act(() => {
+ rerender({ ...hookArgs(false), coldParkTerminalPanes: true, isForceParked: false })
+ })
+ expect(result.current.parkedTerminalTabIds).toEqual(new Set(['tab-1', 'tab-2']))
+ })
+
+ // Why: a split lands in the layout store, not in terminalTabs — a memo keyed
+ // on the tabs alone would keep serving a set that misses the new pane and
+ // unmount the live shell it should have exempted.
+ it('re-resolves exemptions when only the layout PTYs change', () => {
+ const stableArgs = { ...hookArgs(false), coldParkTerminalPanes: true, isForceParked: true }
+ const { result, rerender } = renderHook(
+ (args: typeof stableArgs) => useTerminalTabColdParking(args),
+ { initialProps: stableArgs }
+ )
+ expect(result.current.parkedTerminalTabIds).toEqual(new Set(['tab-1', 'tab-2']))
+
+ // Same tabs, same verdict: nothing the memo can see has moved yet.
+ mocks.exemptTabIds = new Set(['tab-1'])
+ act(() => {
+ rerender(stableArgs)
+ })
+ expect(result.current.parkedTerminalTabIds).toEqual(new Set(['tab-1', 'tab-2']))
+
+ // The split's leaf pty lands in the layout store and re-keys the memo.
+ mocks.storeState.terminalLayoutsByTabId = {
+ 'tab-1': { ptyIdsByLeafId: { 'leaf-2': 'pty-local-detached' } }
+ }
+ act(() => {
+ rerender(stableArgs)
+ })
+ expect(result.current.parkedTerminalTabIds).toEqual(new Set(['tab-2']))
+ })
+
+ // Why: resolving an exemption re-reads the store and walks the layout tree per
+ // tab, so an unrelated re-render must not repeat that work.
+ it('resolves eviction exemptions once per force-park input change', () => {
+ mocks.exemptTabIds = new Set(['tab-1'])
+ const stableArgs = { ...hookArgs(false), coldParkTerminalPanes: true, isForceParked: true }
+ const { result, rerender } = renderHook(
+ (args: typeof stableArgs) => useTerminalTabColdParking(args),
+ { initialProps: stableArgs }
+ )
+ expect(result.current.parkedTerminalTabIds).toEqual(new Set(['tab-2']))
+ const callsAfterFirstRender = mocks.exemptSelectCalls
+
+ act(() => {
+ rerender({ ...stableArgs, isWorktreeActive: false })
+ })
+ expect(mocks.exemptSelectCalls).toBe(callsAfterFirstRender)
+ expect(result.current.parkedTerminalTabIds).toEqual(new Set(['tab-2']))
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts
index 28773698d9b..cbf128e7877 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts
@@ -13,8 +13,9 @@ import {
findActivityTerminalPortal,
type ActivityTerminalPortalTarget
} from '../activity/activity-terminal-portal'
+import { getTerminalTabColdParkRecheckDelayMs } from './terminal-cold-park-recheck-deadlines'
import {
- getTerminalTabColdParkRecheckDelayMs,
+ TERMINAL_TAB_COLD_PARK_DELAY_MS,
selectColdParkedTerminalTabs,
type TerminalTabColdParkCandidate
} from './terminal-hidden-view-parking'
@@ -23,6 +24,10 @@ import {
type ParkVerdictFlipRecord
} from './terminal-park-verdict-flip-telemetry'
import { getTerminalParkingPolicyOverrides } from './terminal-parking-e2e-overrides'
+import {
+ selectEvictionExemptTerminalTabIds,
+ selectEvictionExemptTerminalTabLayoutKey
+} from './terminal-eviction-exempt-tabs'
import {
canWatcherCoverParkedTerminalTab,
disposeParkedTerminalWatchersForWorktree,
@@ -34,6 +39,8 @@ type TerminalOverlayTabAssignment = {
isActiveInGroup: boolean
}
+const EMPTY_TAB_IDS: ReadonlySet = new Set()
+
function haveSameTerminalTabIds(left: ReadonlySet, right: ReadonlySet): boolean {
if (left.size !== right.size) {
return false
@@ -53,6 +60,10 @@ export function useTerminalTabColdParking(args: {
isWorktreeActive: boolean
/** Worktree-level park verdict from Terminal.tsx. */
coldParkTerminalPanes: boolean
+ /** Retention-budget force-park (C1 slice B): unlike ordinary parks, the
+ * worktree may hold eviction-exempt tabs, whose panes must stay mounted —
+ * a remount would orphan their live pty (same carve-out as portals). */
+ isForceParked?: boolean
/** Hidden-measuring startup probe from Terminal.tsx — the panes must stay
* mounted for their first xterm fit, mirroring the worktree-level guard. */
shouldMeasureHiddenWorktree: boolean
@@ -70,6 +81,7 @@ export function useTerminalTabColdParking(args: {
assignments,
isWorktreeActive,
coldParkTerminalPanes,
+ isForceParked = false,
shouldMeasureHiddenWorktree,
activityTerminalPortals,
activationDeferredMountTabIds
@@ -78,7 +90,16 @@ export function useTerminalTabColdParking(args: {
const terminalParkingEnabled = useAppStore(
(state) => state.settings?.terminalHiddenViewParking !== false
)
+ const terminalSshParkingEnabled = useAppStore(
+ (state) => state.settings?.terminalSshViewParking !== false
+ )
const terminalTabHiddenSinceRef = useRef(new Map())
+ // Why (shared measure-clock contract with Terminal.tsx): tab hiddenSince
+ // survives a background-measure window so per-tab park deadlines stay in
+ // sync with the worktree retention/TTL clock, and a post-measure cool-down
+ // re-grants the hysteresis so measure end can't immediately re-park.
+ const wasMeasuringHiddenWorktreeRef = useRef(false)
+ const measureParkCooldownUntilRef = useRef(null)
const terminalTabParkingTimersRef = useRef(new Map())
const parkVerdictRecordsRef = useRef(new Map())
const [terminalTabParkingRevision, setTerminalTabParkingRevision] = useState(0)
@@ -120,15 +141,38 @@ export function useTerminalTabColdParking(args: {
}
}
+ // Why: measure end starts the re-park cool-down (worktree measure-clock
+ // contract) — hiddenSince is preserved through the window, so without the
+ // cool-down every past-deadline tab would re-park the instant it closes.
+ if (shouldMeasureHiddenWorktree) {
+ wasMeasuringHiddenWorktreeRef.current = true
+ } else {
+ if (wasMeasuringHiddenWorktreeRef.current) {
+ measureParkCooldownUntilRef.current =
+ nowMs + (overrides.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS)
+ }
+ wasMeasuringHiddenWorktreeRef.current = false
+ }
+ // Why: mirrors Terminal.tsx's worktree clock — a visible worktree ends the
+ // measure episode outright, so no re-park cool-down is owed.
+ if (isWorktreeActive) {
+ measureParkCooldownUntilRef.current = null
+ }
+
const candidates: TerminalTabColdParkCandidate[] = terminalTabs.map((terminalTab) => {
const assignment = assignments.get(terminalTab.id)
const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup)
const hasActivityTerminalPortal = portalTabIds.has(terminalTab.id)
- // Why: hidden-measuring counts as visibility — the startup probe needs
- // mounted panes, so the hidden clock must not run during it.
- if (isVisible || hasActivityTerminalPortal || shouldMeasureHiddenWorktree) {
+ // Why measuring preserves the clock: the startup probe still needs
+ // mounted panes (selection + render veto below), but deleting
+ // hiddenSince would restart the hysteresis AND desync per-tab deadlines
+ // from the worktree retention/TTL clock on every ~3s probe.
+ if (isVisible || hasActivityTerminalPortal) {
terminalTabHiddenSinceRef.current.delete(terminalTab.id)
- } else if (!terminalTabHiddenSinceRef.current.has(terminalTab.id)) {
+ } else if (
+ !shouldMeasureHiddenWorktree &&
+ !terminalTabHiddenSinceRef.current.has(terminalTab.id)
+ ) {
terminalTabHiddenSinceRef.current.set(terminalTab.id, nowMs)
}
return {
@@ -147,6 +191,8 @@ export function useTerminalTabColdParking(args: {
pendingStartupByTabId,
parkingEnabled: terminalParkingEnabled,
nowMs,
+ parkCooldownUntilMs: measureParkCooldownUntilRef.current,
+ restorePolicy: { sshParkingEnabled: terminalSshParkingEnabled },
...overrides
})
// Why: a tab the byte watchers cannot cover (no capture, no layout
@@ -177,6 +223,7 @@ export function useTerminalTabColdParking(args: {
const delayMs = getTerminalTabColdParkRecheckDelayMs({
parkingEnabled: terminalParkingEnabled,
hiddenSinceMs: candidate.hiddenSinceMs,
+ parkCooldownUntilMs: measureParkCooldownUntilRef.current,
nowMs,
...overrides
})
@@ -196,11 +243,29 @@ export function useTerminalTabColdParking(args: {
pendingStartupByTabId,
shouldMeasureHiddenWorktree,
terminalParkingEnabled,
+ terminalSshParkingEnabled,
terminalTabParkingRevision,
terminalTabs,
worktreeId
])
+ // Why subscribed: the exemption also reads layout leaf PTYs, which change
+ // without a terminalTabs change (split added, pty re-minted); gated on
+ // isForceParked so only force-parked worktrees build the key per store change.
+ const evictionExemptLayoutKey = useAppStore((state) =>
+ isForceParked ? selectEvictionExemptTerminalTabLayoutKey(state, terminalTabs) : ''
+ )
+ // Why memoized: resolving an exemption re-reads the store and walks the
+ // layout tree per tab, so recompute only when the force-park verdict, the
+ // tabs, or their layout PTYs change — not on every assignment/park-set change
+ // below.
+ const evictionExemptTerminalTabIds = useMemo(
+ () =>
+ isForceParked ? selectEvictionExemptTerminalTabIds(worktreeId, terminalTabs) : EMPTY_TAB_IDS,
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- the layout key encodes the store fields the selector re-reads internally.
+ [evictionExemptLayoutKey, isForceParked, terminalTabs, worktreeId]
+ )
+
// Why: the rendered park verdict — worktree-level park (prop from
// Terminal.tsx) or per-tab cold park, never portal-hosted tabs. Render and
// the watcher-sync effect must share this exact set so watcher lifecycle
@@ -218,6 +283,11 @@ export function useTerminalTabColdParking(args: {
if (
(coldParkTerminalPanes || (!isVisible && coldParkedTerminalTabIds.has(terminalTab.id))) &&
!hasActivityTerminalPortal &&
+ // Why: a force-parked worktree's eviction-exempt tabs keep their
+ // mounted panes — a remount would orphan their live pty. Scoped to
+ // force-parks: ordinary parks never contain exempt tabs (eligibility
+ // requires every tab restorable, so the memo is empty for them).
+ !evictionExemptTerminalTabIds.has(terminalTab.id) &&
// Why: the hidden-measuring startup probe needs mounted panes; gate
// here too so the reveal lands in the same render that starts it.
!shouldMeasureHiddenWorktree
@@ -242,6 +312,7 @@ export function useTerminalTabColdParking(args: {
coldParkTerminalPanes,
coldParkedTerminalTabIds,
activationDeferredMountTabIds,
+ evictionExemptTerminalTabIds,
isWorktreeActive,
shouldMeasureHiddenWorktree,
terminalTabs,
diff --git a/src/shared/command-code-output-status.ts b/src/shared/command-code-output-status.ts
index 889c9a787e0..41e4c9eefc1 100644
--- a/src/shared/command-code-output-status.ts
+++ b/src/shared/command-code-output-status.ts
@@ -211,11 +211,16 @@ function isIdlePromptText(context: StatusScanContext): boolean {
export function createCommandCodeOutputStatusDetector(args: {
startupCommand?: string | null
+ /** Continuity seed for a detector created long after launch (parked watchers):
+ * the banner is off-screen by then, so a turn known to be in flight both arms
+ * the scrape and carries its prompt into the idle-composer done check. */
+ inFlightTurn?: { prompt: string } | null
onWorking: (prompt: string) => void
onDone?: (prompt: string) => void
}): CommandCodeOutputStatusDetector {
- let hasSeenCommandCodeUi = isCommandCodeLaunchCommand(args.startupCommand)
- let lastSubmittedPrompt = ''
+ let hasSeenCommandCodeUi =
+ isCommandCodeLaunchCommand(args.startupCommand) || Boolean(args.inFlightTurn)
+ let lastSubmittedPrompt = args.inFlightTurn?.prompt ?? ''
let recentRawText = ''
return {
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index d017a0cdec5..7238c4a0dac 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -300,6 +300,10 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
activeClaudeManagedAccountId: null,
terminalScopeHistoryByWorktree: true,
terminalHiddenViewParking: true,
+ // C1 kill switches — runtime reads stay `!== false` so older persisted
+ // settings objects (which omit them) keep the default-on behavior.
+ terminalSshViewParking: true,
+ terminalHiddenWorktreeRetentionBudget: true,
terminalMainSideEffectAuthority: true,
terminalHiddenDeliveryGate: true,
terminalModelQueryAuthority: true,
diff --git a/src/shared/e2e-config.ts b/src/shared/e2e-config.ts
index e8f34d6bf51..e24ab578625 100644
--- a/src/shared/e2e-config.ts
+++ b/src/shared/e2e-config.ts
@@ -6,6 +6,9 @@ export type E2EConfig = {
/** Test-only override (ORCA_E2E_TERMINAL_PARKING_DELAY_MS) shrinking the
* terminal hidden-view parking delays. null means use production timing. */
terminalParkingDelayMs: number | null
+ /** Test-only override (ORCA_E2E_TERMINAL_RETENTION_LIMIT) shrinking the
+ * hidden un-parkable worktree force-park budget. null means production (12). */
+ terminalRetentionLimit: number | null
}
type E2EConfigInput = {
@@ -13,6 +16,7 @@ type E2EConfigInput = {
exposeStore?: boolean
userDataDir?: string | null
terminalParkingDelayMs?: number | null
+ terminalRetentionLimit?: number | null
}
export function createE2EConfig(input: E2EConfigInput): E2EConfig {
@@ -25,12 +29,20 @@ export function createE2EConfig(input: E2EConfigInput): E2EConfig {
input.terminalParkingDelayMs > 0
? input.terminalParkingDelayMs
: null
+ // Why: a worktree count — only a positive integer is a meaningful budget.
+ const terminalRetentionLimit =
+ typeof input.terminalRetentionLimit === 'number' &&
+ Number.isInteger(input.terminalRetentionLimit) &&
+ input.terminalRetentionLimit > 0
+ ? input.terminalRetentionLimit
+ : null
return {
enabled: headless || exposeStore || userDataDir !== null,
headless,
exposeStore,
userDataDir,
- terminalParkingDelayMs
+ terminalParkingDelayMs,
+ terminalRetentionLimit
}
}
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 35c967ceadf..0aac474ca04 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -2855,6 +2855,10 @@ export type GlobalSettings = {
terminalScopeHistoryByWorktree: boolean
/** Kill switch for hidden terminal view parking: unmount long-hidden panes while a pane-less watcher keeps PTY side effects alive. */
terminalHiddenViewParking?: boolean
+ /** Kill switch for SSH terminal parking (C1): SSH panes park like local ones; reveal restores from main's headless model, falling back to relay replay. */
+ terminalSshViewParking?: boolean
+ /** Kill switch for the hidden-worktree retention budget (C1): force-parks the least-recently-hidden un-parkable worktrees beyond a count budget or TTL. */
+ terminalHiddenWorktreeRetentionBudget?: boolean
/** Kill switch for main-process PTY side-effect authority; on (default) = title/bell/agent facts via pty:sideEffect channel, not renderer byte parsing. */
terminalMainSideEffectAuthority?: boolean
/** Kill switch for main's hidden-delivery gate (Phase 4): drops PTY bytes to hidden views after model ingestion; requires terminalMainSideEffectAuthority. */
diff --git a/tests/e2e/helpers/docker-ssh-relay-worktree-activation.ts b/tests/e2e/helpers/docker-ssh-relay-worktree-activation.ts
new file mode 100644
index 00000000000..afe54b45347
--- /dev/null
+++ b/tests/e2e/helpers/docker-ssh-relay-worktree-activation.ts
@@ -0,0 +1,67 @@
+import type { Page } from '@stablyai/playwright-test'
+import { expect } from '@stablyai/playwright-test'
+
+// Why: the retention-budget spec needs several worktrees of ONE connected remote
+// repo (adding a second repo mid-session misroutes its pty spawn to the local
+// daemon — pre-existing multi-repo issue). Creation goes through the product's
+// own createWorktree path so the result lands in worktreesByRepo (an external
+// `git worktree add` only shows up as a detected worktree needing adoption).
+// The create is polled: the relay channel can drop and reconnect shortly after
+// connect, and an RPC inside that window fails with "Multiplexer disposed".
+export async function createAndActivateDockerSshRelayWorktree(
+ page: Page,
+ repoId: string,
+ worktreeName: string
+): Promise<{ worktreeId: string }> {
+ let worktreeId: string | null = null
+ await expect
+ .poll(
+ async () => {
+ worktreeId = await page.evaluate(
+ async ({ repoId, worktreeName }) => {
+ const store = window.__store
+ if (!store) {
+ throw new Error('Store unavailable')
+ }
+ // Why: a retried create must reuse a prior attempt's worktree
+ // instead of failing forever on "already exists".
+ const existing = (store.getState().worktreesByRepo[repoId] ?? []).find((candidate) =>
+ candidate.path.endsWith(`/${worktreeName}`)
+ )
+ if (existing) {
+ return existing.id
+ }
+ try {
+ const result = await store.getState().createWorktree(repoId, worktreeName)
+ await store.getState().fetchWorktrees(repoId)
+ return result.worktree.id
+ } catch {
+ return null
+ }
+ },
+ { repoId, worktreeName }
+ )
+ return worktreeId
+ },
+ {
+ timeout: 90_000,
+ message: `remote worktree ${worktreeName} was not created in repo ${repoId}`
+ }
+ )
+ .not.toBeNull()
+ if (!worktreeId) {
+ throw new Error(`remote worktree ${worktreeName} did not resolve an id`)
+ }
+ await page.evaluate((id) => {
+ const store = window.__store
+ if (!store) {
+ throw new Error('Store unavailable')
+ }
+ store.getState().setActiveWorktree(id)
+ if ((store.getState().tabsByWorktree[id] ?? []).length === 0) {
+ store.getState().createTab(id)
+ }
+ store.getState().setActiveTabType('terminal')
+ }, worktreeId)
+ return { worktreeId }
+}
diff --git a/tests/e2e/helpers/terminal-hidden-parking.ts b/tests/e2e/helpers/terminal-hidden-parking.ts
index 4b15f656ec2..d6eaa2cb805 100644
--- a/tests/e2e/helpers/terminal-hidden-parking.ts
+++ b/tests/e2e/helpers/terminal-hidden-parking.ts
@@ -49,17 +49,20 @@ async function createActiveTerminalTab(page: Page, worktreeId: string): Promise<
return tabId
}
-// Why: #8262 exempts the single most-recently-hidden tab from cold-park to keep
-// the just-left view instantly warm, so a lone hidden tab never parks. Opening
-// one more tab hides the current view (which then holds that exemption) and
-// leaves the older `targetTabId` free to cold-park. Returns waitForTabParked's
-// elapsed-ms so callers keep their parking annotations.
+// Why: #8262 exempts the single most-recently-hidden tab from cold-park. An
+// active target therefore needs two decoys (hide it, then move the exemption);
+// an already-hidden target needs one. Returns waitForTabParked's elapsed time.
export async function parkHiddenTabBehindDecoy(
page: Page,
worktreeId: string,
targetTabId: string,
options?: { parkDelayMs?: number }
): Promise {
+ // An active target needs one decoy to become old and another to take the
+ // last-active exemption; already-hidden targets need only the latter.
+ if ((await getActiveTabId(page)) === targetTabId) {
+ await createActiveTerminalTab(page, worktreeId)
+ }
await createActiveTerminalTab(page, worktreeId)
return waitForTabParked(page, targetTabId, options)
}
diff --git a/tests/e2e/ssh-terminal-parking.spec.ts b/tests/e2e/ssh-terminal-parking.spec.ts
new file mode 100644
index 00000000000..951ad70ddc5
--- /dev/null
+++ b/tests/e2e/ssh-terminal-parking.spec.ts
@@ -0,0 +1,115 @@
+import type { TestInfo } from '@stablyai/playwright-test'
+import { test, expect } from './helpers/orca-app'
+import { waitForActiveWorktree, waitForSessionReady, getActiveTabId } from './helpers/store'
+import {
+ getTerminalContent,
+ sendToTerminal,
+ waitForActivePanePtyId,
+ waitForActiveTerminalManager,
+ waitForPaneIdentitySnapshot
+} from './helpers/terminal'
+import { parkHiddenTabBehindDecoy } from './helpers/terminal-hidden-parking'
+import {
+ cleanupDockerSshRelayTarget,
+ startDockerSshRelayTarget,
+ type DockerSshRelayTarget
+} from './helpers/docker-ssh-relay-target'
+import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection'
+
+const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1'
+const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500
+
+test.use({
+ seedTestRepo: false,
+ orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) }
+})
+
+// C1 slice A: SSH tabs park like local ones and reveal restores content from
+// main's headless model (relay replay is the fallback). This is the SSH
+// park+reveal round-trip fidelity check the design gate required.
+test.describe('SSH terminal hidden view parking', () => {
+ test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.')
+ test.skip(process.platform === 'win32', 'Docker SSH parking uses POSIX SSH tooling.')
+
+ test('parks a hidden SSH tab and restores its scrollback on reveal', async ({
+ orcaPage
+ }, testInfo: TestInfo) => {
+ test.setTimeout(240_000)
+ let target: DockerSshRelayTarget | null = null
+ try {
+ target = startDockerSshRelayTarget(testInfo)
+ await waitForSessionReady(orcaPage)
+ const remote = await connectDockerSshRelayTarget(orcaPage, target)
+ await expect
+ .poll(() => waitForActiveWorktree(orcaPage), { timeout: 30_000 })
+ .toBe(remote.worktreeId)
+ await waitForActiveTerminalManager(orcaPage, 60_000)
+ const sshPtyId = await waitForActivePanePtyId(orcaPage, 60_000)
+ const sshTabId = await getActiveTabId(orcaPage)
+ if (!sshTabId) {
+ throw new Error('SSH terminal tab did not become active')
+ }
+ const snapshot = await waitForPaneIdentitySnapshot(orcaPage, 1)
+ expect(snapshot.panes[0]?.ptyId).toBe(sshPtyId)
+
+ // Why the ':' terminator: `${marker}_1:` must not substring-match _10/_100.
+ const marker = `SSH_PARK_MARKER_${Date.now()}`
+ await sendToTerminal(
+ orcaPage,
+ sshPtyId,
+ `for i in $(seq 1 200); do echo "${marker}_$i:"; done\r`
+ )
+ await expect
+ .poll(() => getTerminalContent(orcaPage, 20_000), {
+ timeout: 30_000,
+ message: 'SSH marker output did not render before parking'
+ })
+ .toContain(`${marker}_200:`)
+ // Why the pad: ~3000 × ~60B ≈ 180KB pushes the early markers past the
+ // relay's 100KiB rolling replay buffer while staying inside main's
+ // ~5k-row headless model — so a revealed `${marker}_1:` can only have
+ // come from the model paint, never the relay fallback.
+ await sendToTerminal(
+ orcaPage,
+ sshPtyId,
+ `for i in $(seq 1 3000); do echo "PAD_$i:0123456789012345678901234567890123456789"; done; echo "${marker}_PAD_DONE:"\r`
+ )
+ await expect
+ .poll(() => getTerminalContent(orcaPage, 20_000), {
+ timeout: 60_000,
+ message: 'SSH pad output did not finish before parking'
+ })
+ .toContain(`${marker}_PAD_DONE:`)
+
+ await parkHiddenTabBehindDecoy(orcaPage, remote.worktreeId, sshTabId, {
+ parkDelayMs: PARKING_DELAY_MS
+ })
+
+ // Reveal: reattach must paint from main's headless model (or relay
+ // replay when the model is unavailable) — never a blank pane.
+ await orcaPage.evaluate((tabId) => {
+ const state = window.__store?.getState()
+ state?.setActiveTab(tabId)
+ state?.setActiveTabType('terminal')
+ }, sshTabId)
+ await waitForActiveTerminalManager(orcaPage, 60_000)
+ await expect
+ .poll(() => getTerminalContent(orcaPage, 20_000), {
+ timeout: 60_000,
+ message: 'revealed SSH tab did not restore the final pad line'
+ })
+ .toContain(`${marker}_PAD_DONE:`)
+ // Depth proof: `${marker}_1:` predates >100KiB of later output, so its
+ // presence after reveal proves the headless-model paint restored
+ // scrollback the relay replay cannot hold.
+ await expect
+ .poll(() => getTerminalContent(orcaPage, 2_000_000), {
+ timeout: 15_000,
+ message: 'revealed SSH tab lost the pre-pad scrollback only the model paint restores'
+ })
+ .toContain(`${marker}_1:`)
+ } finally {
+ cleanupDockerSshRelayTarget(target)
+ }
+ })
+})
diff --git a/tests/e2e/terminal-parked-memory.spec.ts b/tests/e2e/terminal-parked-memory.spec.ts
index 52ce3693297..83b2e32da7d 100644
--- a/tests/e2e/terminal-parked-memory.spec.ts
+++ b/tests/e2e/terminal-parked-memory.spec.ts
@@ -6,6 +6,8 @@ import { test, expect } from './helpers/orca-app'
import {
ensureTerminalVisible,
getActiveTabId,
+ getAllWorktreeIds,
+ switchToWorktree,
waitForActiveWorktree,
waitForSessionReady
} from './helpers/store'
@@ -49,13 +51,17 @@ const PARKED_MEMORY_TEST_TIMEOUT_MS = 300_000
// Why: mixed-width content (ASCII, CJK wide cells, emoji, box drawing) makes
// each xterm hold realistic narrow+wide buffer rows, so released parked-tab
// memory reflects real agent output rather than uniform filler.
-function writeScrollbackFillScript(scriptPath: string, runId: string): void {
+function writeScrollbackFillScript(
+ scriptPath: string,
+ runId: string,
+ lineCount: number = SCROLLBACK_LINE_COUNT
+): void {
const script = [
`const tabIndex = process.argv[2] ?? '0'`,
`const wide = '統合端末記憶計測'`,
`const emoji = ['🟢', '🟡', '🔵', '🟣']`,
`const lines = []`,
- `for (let i = 0; i < ${SCROLLBACK_LINE_COUNT}; i += 1) {`,
+ `for (let i = 0; i < ${lineCount}; i += 1) {`,
` const ascii = ('tab ' + tabIndex + ' line ' + String(i).padStart(4, '0') + ' ').padEnd(48, 'abcdefghijklmnopqrstuvwxyz')`,
` const box = '│' + '─'.repeat(8 + (i % 24)) + '│'`,
` lines.push(ascii + ' ' + wide.repeat(1 + (i % 3)) + ' ' + emoji[i % 4] + ' ' + box)`,
@@ -369,3 +375,406 @@ test.describe('Terminal parked memory', () => {
}
})
})
+
+// ─────────────────────── C1 retention budget outcome ───────────────────────
+// The tests above assert the parking MECHANISM. This one asserts the OUTCOME
+// the retention budget exists for: hidden worktrees ordinary parking can never
+// evict actually release their buffers once the budget engages, with the
+// budget flip as the only change between the two samples.
+//
+// Honest caveats:
+// - The un-parkable class is staged by rewriting tabsByWorktree[*].ptyId to a
+// remote-runtime-shaped id. That is a fidelity proxy: park-restorability and
+// eviction-exemption are decided from that field alone, but the transports
+// underneath stay real LOCAL PTYs — this is not a live remote runtime.
+// - The primary gate is retained xterm buffer CELLS (deterministic), not RSS.
+// xterm rows are typed arrays outside the V8 heap, so usedJSHeapSize misses
+// most of what is released; renderer RSS is recorded and only gated as
+// non-growth because it moves with GC timing and compositor allocations.
+const RETENTION_TAB_COUNT = 4
+const RETENTION_FILL_LINE_COUNT = 12_000
+const RETENTION_SCROLLBACK_ROWS = 25_000
+// Why 12: xterm packs each cell as 3 uint32s in the BufferLine typed array.
+const XTERM_BYTES_PER_CELL = 12
+// Why 40: this staging measures ~87 MB of retained buffer, so half of that is a
+// floor that fails loudly if the fill silently stops producing scrollback.
+const MIN_STAGED_BUFFER_MB = 40
+// Why 2s: force-park is a synchronous verdict re-run on the setting flip and
+// measured 23-25ms locally; anything near a timer/TTL wait blows this budget.
+const MAX_FORCE_PARK_EVICTION_MS = 2_000
+// Why 0.05: only the exempt decoy's unfilled pane may survive (~0.1% of the
+// baseline). One retained filled pane would be ~25%, so this fails on a partial
+// eviction instead of passing it.
+const MAX_RETAINED_CELL_FRACTION = 0.05
+// Why a band, not zero: RSS is sampled and moved only -0.7 to -3.0 MB on a
+// ~453 MB baseline across runs, so "must not grow" would flake on noise. 5 MB
+// still fails loudly if an eviction starts planting 512KB/pane capture strings.
+const RENDERER_RSS_NOISE_MB = 5
+const RETENTION_TEST_TIMEOUT_MS = 420_000
+const UNPARKABLE_PTY_PREFIX = 'remote:e2e-retention-'
+
+type RetainedBufferSample = {
+ cells: number
+ rows: number
+ panes: number
+}
+
+// Why walk the buffers instead of trusting a heap delta: xterm rows live in
+// typed arrays outside the V8 heap, so retained cells are the only
+// deterministic measure of what a force-park actually released.
+async function readRetainedTerminalBufferCells(page: Page): Promise {
+ return page.evaluate(() => {
+ let cells = 0
+ let rows = 0
+ let panes = 0
+ for (const manager of window.__paneManagers?.values() ?? []) {
+ for (const pane of manager.getPanes?.() ?? []) {
+ const buffer = pane.terminal?.buffer?.active
+ if (!buffer) {
+ continue
+ }
+ panes += 1
+ rows += buffer.length
+ cells += buffer.length * pane.terminal.cols
+ }
+ }
+ return { cells, rows, panes }
+ })
+}
+
+// Why host-side RSS: the renderer process' resident set is the figure the C1
+// crash reports are measured in, and performance.memory cannot see it.
+async function readRendererResidentMb(page: Page): Promise {
+ return page.evaluate(async () => {
+ const snapshot = await window.api?.memory?.getSnapshot?.()
+ const bytes = snapshot?.app?.renderer?.memory
+ return typeof bytes === 'number' && bytes > 0 ? bytes / (1024 * 1024) : null
+ })
+}
+
+type RetentionMemorySample = {
+ buffers: RetainedBufferSample
+ bufferMb: number
+ heapUsedMb: number
+ rendererMb: number | null
+ livePaneManagers: number
+}
+
+async function readRetentionMemorySample(page: Page): Promise {
+ const metrics = await sampleParkedMemoryMetrics(page)
+ const buffers = await readRetainedTerminalBufferCells(page)
+ return {
+ buffers,
+ bufferMb: (buffers.cells * XTERM_BYTES_PER_CELL) / (1024 * 1024),
+ heapUsedMb: metrics.heapUsedMB,
+ rendererMb: await readRendererResidentMb(page),
+ livePaneManagers: metrics.livePaneManagers
+ }
+}
+
+function formatRetentionSample(label: string, sample: RetentionMemorySample): string {
+ return [
+ `${label}.cells=${sample.buffers.cells}`,
+ `${label}.rows=${sample.buffers.rows}`,
+ `${label}.panes=${sample.buffers.panes}`,
+ `${label}.bufferMB=${sample.bufferMb.toFixed(1)}`,
+ `${label}.heapMB=${sample.heapUsedMb.toFixed(1)}`,
+ `${label}.rendererRssMB=${sample.rendererMb === null ? 'n/a' : sample.rendererMb.toFixed(1)}`,
+ `${label}.paneManagers=${sample.livePaneManagers}`
+ ].join(' ')
+}
+
+// Why rewrite only tab.ptyId: canPark* eligibility is decided from it, so a
+// remote-runtime-shaped id makes the worktree un-parkable. Layout leaf maps
+// stay on real local transports so live panes keep their bindings; watcher
+// coverage may still read those locals, but eligibility already fails.
+async function stageUnparkableWorktreeTabs(page: Page, worktreeId: string): Promise {
+ return page.evaluate(
+ ({ worktreeId, prefix }) => {
+ const store = window.__store
+ if (!store) {
+ throw new Error('stageUnparkableWorktreeTabs: window.__store is unavailable')
+ }
+ const state = store.getState()
+ const tabs = state.tabsByWorktree[worktreeId] ?? []
+ if (tabs.length === 0) {
+ throw new Error(`stageUnparkableWorktreeTabs: ${worktreeId} has no terminal tabs`)
+ }
+ const staged = tabs.map((tab) =>
+ typeof tab.ptyId === 'string' && tab.ptyId.startsWith(prefix)
+ ? tab
+ : { ...tab, ptyId: `${prefix}${tab.id}` }
+ )
+ ;(store as unknown as { setState: (partial: unknown) => void }).setState({
+ tabsByWorktree: { ...state.tabsByWorktree, [worktreeId]: staged }
+ })
+ return staged.length
+ },
+ { worktreeId, prefix: UNPARKABLE_PTY_PREFIX }
+ )
+}
+
+// Why: bind/reconcile can rewrite tab.ptyId after staging. Poll so ordinary
+// parking cannot regain park-restorable eligibility mid control-arm wait.
+async function waitForUnparkableWorktreeTabs(page: Page, worktreeId: string): Promise {
+ await expect
+ .poll(
+ () =>
+ page.evaluate(
+ ({ worktreeId, prefix }) => {
+ const tabs = window.__store?.getState().tabsByWorktree[worktreeId] ?? []
+ return (
+ tabs.length > 0 &&
+ tabs.every((tab) => typeof tab.ptyId === 'string' && tab.ptyId.startsWith(prefix))
+ )
+ },
+ { worktreeId, prefix: UNPARKABLE_PTY_PREFIX }
+ ),
+ {
+ timeout: 5_000,
+ message: `worktree ${worktreeId} did not keep un-parkable remote: pty ids after staging`
+ }
+ )
+ .toBe(true)
+}
+
+// Why not getTerminalContent: it serializes the whole scrollback, which is
+// megabytes of string per poll tick at RETENTION_SCROLLBACK_ROWS. The fill
+// marker is the last line written, so scanning the buffer tail is enough.
+async function waitForFillMarkerInTab(page: Page, tabId: string, marker: string): Promise {
+ await expect
+ .poll(
+ () =>
+ page.evaluate(
+ ({ tabId, marker }) => {
+ const manager = window.__paneManagers?.get(tabId)
+ const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
+ const buffer = pane?.terminal?.buffer?.active
+ if (!buffer) {
+ return false
+ }
+ const firstRow = Math.max(0, buffer.length - 200)
+ for (let row = buffer.length - 1; row >= firstRow; row -= 1) {
+ if (buffer.getLine(row)?.translateToString(true).includes(marker) === true) {
+ return true
+ }
+ }
+ return false
+ },
+ { tabId, marker }
+ ),
+ { timeout: 90_000, message: `scrollback fill marker ${marker} did not render` }
+ )
+ .toBe(true)
+}
+
+async function updateTerminalSettings(
+ page: Page,
+ patch: { terminalHiddenWorktreeRetentionBudget?: boolean; terminalScrollbackRows?: number }
+): Promise {
+ await page.evaluate(async (patch) => {
+ const store = window.__store
+ if (!store) {
+ throw new Error('updateTerminalSettings: window.__store is unavailable')
+ }
+ await store.getState().updateSettings(patch)
+ }, patch)
+}
+
+async function waitForRetentionBudgetSetting(page: Page, enabled: boolean): Promise {
+ await expect
+ .poll(
+ () =>
+ page.evaluate(
+ () => window.__store?.getState().settings?.terminalHiddenWorktreeRetentionBudget
+ ),
+ { timeout: 5_000, message: `terminalHiddenWorktreeRetentionBudget did not become ${enabled}` }
+ )
+ .toBe(enabled)
+}
+
+test.describe('Terminal hidden worktree retention budget', () => {
+ test.use({
+ orcaAppExtraEnv: {
+ ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS),
+ // Why limit=1: the retention TTL is absolute production timing (45min) and
+ // the parking-delay override deliberately no longer shrinks it, so the
+ // COUNT CAP is the only knob a test can drive. With a budget of 1 the
+ // newest hidden un-parkable worktree takes the last-active exemption and
+ // the older one force-parks — cap and exemption proven in one run.
+ ORCA_E2E_TERMINAL_RETENTION_LIMIT: '1'
+ },
+ orcaAppExtraArgs: ['--enable-precise-memory-info']
+ })
+
+ test('releases un-parkable hidden worktree buffers only once the retention budget engages', async ({
+ orcaPage,
+ testRepoPath
+ }, testInfo: TestInfo) => {
+ test.setTimeout(RETENTION_TEST_TIMEOUT_MS)
+ await waitForSessionReady(orcaPage)
+ const victimWorktreeId = await waitForActiveWorktree(orcaPage)
+ await skipUnlessParkingWired(orcaPage)
+
+ const decoyWorktreeId = (await getAllWorktreeIds(orcaPage)).find(
+ (worktreeId) => worktreeId !== victimWorktreeId
+ )
+ if (!decoyWorktreeId) {
+ throw new Error('retention budget spec: the fixture seeded only one worktree')
+ }
+
+ // Budget OFF for the whole staging phase: that is the control arm proving
+ // ordinary parking can never evict this class, and it makes the release
+ // below attributable to the flip alone.
+ await updateTerminalSettings(orcaPage, {
+ terminalHiddenWorktreeRetentionBudget: false,
+ terminalScrollbackRows: RETENTION_SCROLLBACK_ROWS
+ })
+ await waitForRetentionBudgetSetting(orcaPage, false)
+
+ const runId = randomUUID()
+ const scriptPath = path.join(testRepoPath, `.orca-retention-memory-${runId}.mjs`)
+ writeScrollbackFillScript(scriptPath, runId, RETENTION_FILL_LINE_COUNT)
+ try {
+ await ensureTerminalVisible(orcaPage)
+ await waitForActiveTerminalManager(orcaPage, 30_000)
+ const baselineSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1)
+ const baselinePtyId = baselineSnapshot.panes[0]?.ptyId
+ if (!baselinePtyId) {
+ throw new Error('retention budget spec: baseline terminal tab did not bind a PTY')
+ }
+
+ const victimTabs: ScrollbackTab[] = [{ tabId: baselineSnapshot.tabId, ptyId: baselinePtyId }]
+ for (let tabIndex = 0; tabIndex < RETENTION_TAB_COUNT; tabIndex += 1) {
+ if (tabIndex > 0) {
+ victimTabs.push(await createActiveTerminalTab(orcaPage, victimWorktreeId))
+ }
+ const tab = victimTabs[tabIndex]
+ await sendToTerminal(
+ orcaPage,
+ tab.ptyId,
+ `node ${JSON.stringify(scriptPath)} ${tabIndex}\r`
+ )
+ await waitForFillMarkerInTab(
+ orcaPage,
+ tab.tabId,
+ `PARKED_MEMORY_FILL_DONE_${runId}_${tabIndex}`
+ )
+ // Why stage after every fill rather than once at the end: each later
+ // fill takes seconds, and ordinary TAB-level parking would evict the
+ // already-hidden earlier tabs inside that window.
+ await stageUnparkableWorktreeTabs(orcaPage, victimWorktreeId)
+ }
+
+ // Hiding the victim first makes the decoy the more-recently-hidden
+ // candidate, so the cap's last-active exemption lands on the decoy.
+ await switchToWorktree(orcaPage, decoyWorktreeId)
+ await expect
+ .poll(() => orcaPage.evaluate(() => window.__store?.getState().activeWorktreeId), {
+ timeout: 5_000,
+ message: 'decoy worktree did not become active before staging'
+ })
+ .toBe(decoyWorktreeId)
+ await ensureTerminalVisible(orcaPage)
+ await waitForActiveTerminalManager(orcaPage, 30_000)
+ // Why the active snapshot (not getWorktreeTabs alone): only tabs that
+ // actually bound a PaneManager can prove retention; empty/deferred ids
+ // would make the control arm look like a budget failure.
+ const decoySnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1)
+ const decoyTabIds = [decoySnapshot.tabId]
+ expect(await countMountedPaneManagers(orcaPage, decoyTabIds)).toBe(1)
+
+ // Leaving the terminal view hides BOTH worktrees while keeping them
+ // mounted (App.tsx hides the workbench, it does not unmount it).
+ await orcaPage.evaluate(() => {
+ window.__store?.getState().setActiveView('tasks')
+ })
+ // Why stage AFTER hide: while a pane is visible/active, bind can rewrite
+ // our remote: fake ids back onto tab/layout state, so the decoy looks
+ // park-restorable and ordinary parking unmounts it during the control
+ // arm. Staging only once both are hidden keeps classification stable.
+ await stageUnparkableWorktreeTabs(orcaPage, victimWorktreeId)
+ await stageUnparkableWorktreeTabs(orcaPage, decoyWorktreeId)
+ await waitForUnparkableWorktreeTabs(orcaPage, victimWorktreeId)
+ await waitForUnparkableWorktreeTabs(orcaPage, decoyWorktreeId)
+
+ const victimTabIds = victimTabs.map((tab) => tab.tabId)
+ expect(victimTabIds).toHaveLength(RETENTION_TAB_COUNT)
+ // Control arm: stay hidden past the ordinary parking window with budget
+ // still off. Re-stage inside the poll so a late bind cannot restore
+ // park-restorable+coverable ids and unmount mid-wait.
+ const controlArmStartedAt = Date.now()
+ await expect
+ .poll(
+ async () => {
+ await stageUnparkableWorktreeTabs(orcaPage, victimWorktreeId)
+ await stageUnparkableWorktreeTabs(orcaPage, decoyWorktreeId)
+ const victimMounted = await countMountedPaneManagers(orcaPage, victimTabIds)
+ const decoyMounted = await countMountedPaneManagers(orcaPage, decoyTabIds)
+ const heldLongEnough = Date.now() - controlArmStartedAt >= PARKING_DELAY_MS * 4
+ return {
+ victimMounted,
+ decoyMounted,
+ heldLongEnough
+ }
+ },
+ {
+ timeout: Math.max(30_000, PARKING_DELAY_MS * 10),
+ message:
+ 'control arm: un-parkable hidden worktrees did not stay mounted for the parking window'
+ }
+ )
+ .toEqual({
+ victimMounted: RETENTION_TAB_COUNT,
+ decoyMounted: 1,
+ heldLongEnough: true
+ })
+ await waitForUnparkableWorktreeTabs(orcaPage, victimWorktreeId)
+ await waitForUnparkableWorktreeTabs(orcaPage, decoyWorktreeId)
+
+ const before = await readRetentionMemorySample(orcaPage)
+ expect(before.bufferMb).toBeGreaterThan(MIN_STAGED_BUFFER_MB)
+
+ const flipStartedAt = Date.now()
+ await updateTerminalSettings(orcaPage, { terminalHiddenWorktreeRetentionBudget: true })
+ await waitForRetentionBudgetSetting(orcaPage, true)
+ await expect
+ .poll(() => countMountedPaneManagers(orcaPage, victimTabIds), {
+ timeout: 30_000,
+ message: 'retention budget did not force-park the older hidden un-parkable worktree'
+ })
+ .toBe(0)
+ const evictionMs = Date.now() - flipStartedAt
+
+ const after = await readRetentionMemorySample(orcaPage)
+ testInfo.annotations.push({
+ type: 'terminal-retention-budget-memory',
+ description: [
+ `stagedTabs=${RETENTION_TAB_COUNT}`,
+ `scrollbackRows=${RETENTION_SCROLLBACK_ROWS}`,
+ formatRetentionSample('before', before),
+ formatRetentionSample('after', after),
+ `releasedBufferMB=${(before.bufferMb - after.bufferMb).toFixed(1)}`,
+ `evictionMs=${evictionMs}`
+ ].join(' ')
+ })
+
+ // Primary gate: the staged buffers are gone, not merely hidden.
+ expect(after.buffers.cells).toBeLessThan(before.buffers.cells * MAX_RETAINED_CELL_FRACTION)
+ // The decoy holds the cap's last-active exemption, so it stays mounted —
+ // this is the same run proving the cap did not simply evict everything.
+ expect(await countMountedPaneManagers(orcaPage, decoyTabIds)).toBe(decoyTabIds.length)
+ // Secondary only: freed typed arrays return to the allocator's free lists,
+ // not the OS, so RSS fell just 0.7-3.0 MB locally while 87 MB of buffer was
+ // released — a strict non-growth assertion would be reading sampling noise.
+ // What this CAN catch is the inverse regression the capture path risks:
+ // force-park planting serialized scrollback in the store as it evicts.
+ if (before.rendererMb !== null && after.rendererMb !== null) {
+ expect(after.rendererMb).toBeLessThanOrEqual(before.rendererMb + RENDERER_RSS_NOISE_MB)
+ }
+ expect(evictionMs).toBeLessThan(MAX_FORCE_PARK_EVICTION_MS)
+ } finally {
+ rmSync(scriptPath, { force: true })
+ }
+ })
+})
diff --git a/tests/e2e/terminal-retention-budget.spec.ts b/tests/e2e/terminal-retention-budget.spec.ts
new file mode 100644
index 00000000000..d877a29cee2
--- /dev/null
+++ b/tests/e2e/terminal-retention-budget.spec.ts
@@ -0,0 +1,142 @@
+import type { TestInfo } from '@stablyai/playwright-test'
+import { test, expect } from './helpers/orca-app'
+import { getActiveTabId, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
+import {
+ getTerminalContent,
+ sendToTerminal,
+ waitForActivePanePtyId,
+ waitForActiveTerminalManager
+} from './helpers/terminal'
+import { waitForTabParked } from './helpers/terminal-hidden-parking'
+import {
+ cleanupDockerSshRelayTarget,
+ startDockerSshRelayTarget,
+ type DockerSshRelayTarget
+} from './helpers/docker-ssh-relay-target'
+import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection'
+import { createAndActivateDockerSshRelayWorktree } from './helpers/docker-ssh-relay-worktree-activation'
+
+const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1'
+const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500
+
+test.use({
+ // Why no seeded local repo: matching every green Docker SSH spec — the same
+ // mid-session repo-add misroute hits a remote repo added beside a local one.
+ seedTestRepo: false,
+ orcaAppExtraEnv: {
+ ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS),
+ // Why limit=1: two hidden un-parkable worktrees then exceed the budget while
+ // the last-active exemption still spares exactly one — the smallest live proof.
+ ORCA_E2E_TERMINAL_RETENTION_LIMIT: '1'
+ }
+})
+
+// C1 slice B: hidden worktrees ordinary parking can never evict (here: SSH with
+// slice A's terminalSshViewParking off) force-park beyond the retention budget,
+// least-recently-hidden first, and reveal restores content from the relay replay.
+test.describe('terminal hidden-worktree retention budget', () => {
+ test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.')
+ test.skip(process.platform === 'win32', 'Docker SSH parking uses POSIX SSH tooling.')
+
+ test('force-parks the older hidden un-parkable worktree and spares the newest', async ({
+ orcaPage
+ }, testInfo: TestInfo) => {
+ test.setTimeout(240_000)
+ let target: DockerSshRelayTarget | null = null
+ try {
+ target = startDockerSshRelayTarget(testInfo)
+ await waitForSessionReady(orcaPage)
+
+ const older = await connectDockerSshRelayTarget(orcaPage, target)
+ await expect
+ .poll(() => waitForActiveWorktree(orcaPage), { timeout: 30_000 })
+ .toBe(older.worktreeId)
+ await waitForActiveTerminalManager(orcaPage, 60_000)
+ const olderPtyId = await waitForActivePanePtyId(orcaPage, 60_000)
+ const olderTabId = await getActiveTabId(orcaPage)
+ if (!olderTabId) {
+ throw new Error('older SSH terminal tab did not become active')
+ }
+ // Why the ':' terminator: match the exact echoed line, not the typed command.
+ const olderMarker = `RETENTION_OLD_${Date.now()}`
+ await sendToTerminal(orcaPage, olderPtyId, `echo "${olderMarker}:"\r`)
+ await expect
+ .poll(() => getTerminalContent(orcaPage, 20_000), {
+ timeout: 30_000,
+ message: 'older worktree marker did not render before hiding'
+ })
+ .toContain(`${olderMarker}:`)
+
+ // Why: with SSH view parking off, SSH ptys are not park-restorable, so the
+ // hidden remote worktrees join the un-parkable class the budget governs.
+ // Written after the first terminal is live so it cannot race target setup;
+ // parking eligibility reads it at verdict time, not spawn time.
+ await orcaPage.evaluate(async () => {
+ await window.__store?.getState().updateSettings({ terminalSshViewParking: false })
+ })
+
+ // Why worktrees of ONE remote repo: the retention budget ranks worktrees,
+ // and a repo added mid-session misroutes its pty spawn to the local daemon
+ // (pre-existing multi-repo issue, independent of retention).
+ // Activating the second worktree hides the older one, making the older the
+ // least-recently-hidden candidate.
+ const newer = await createAndActivateDockerSshRelayWorktree(
+ orcaPage,
+ older.repoId,
+ 'retention-newer'
+ )
+ await expect
+ .poll(() => waitForActiveWorktree(orcaPage), { timeout: 30_000 })
+ .toBe(newer.worktreeId)
+ await waitForActiveTerminalManager(orcaPage, 60_000)
+ await waitForActivePanePtyId(orcaPage, 60_000)
+ const newerTabId = await getActiveTabId(orcaPage)
+ if (!newerTabId) {
+ throw new Error('newer SSH terminal tab did not become active')
+ }
+
+ // Third context: activating it hides BOTH earlier worktrees — two hidden
+ // un-parkable worktrees against a budget of one. It stays visible, so it
+ // is never a retention candidate itself.
+ const third = await createAndActivateDockerSshRelayWorktree(
+ orcaPage,
+ older.repoId,
+ 'retention-third'
+ )
+ await expect
+ .poll(() => waitForActiveWorktree(orcaPage), { timeout: 30_000 })
+ .toBe(third.worktreeId)
+ await waitForActiveTerminalManager(orcaPage, 60_000)
+
+ // The older worktree must force-park (its pane managers unmount)…
+ await waitForTabParked(orcaPage, olderTabId, { parkDelayMs: PARKING_DELAY_MS })
+ // …while the newest hidden worktree keeps its mounted panes (last-active exemption).
+ const newerStillMounted = await orcaPage.evaluate(
+ (tabId) => window.__paneManagers?.get(tabId) !== undefined,
+ newerTabId
+ )
+ expect(newerStillMounted).toBe(true)
+
+ // Reveal the evicted worktree: with SSH parking disabled the model paint is
+ // off, so the relay replay must restore the marker tail — never a blank pane.
+ await orcaPage.evaluate(
+ ({ worktreeId, tabId }) => {
+ const state = window.__store?.getState()
+ state?.setActiveWorktree(worktreeId)
+ state?.setActiveTab(tabId)
+ state?.setActiveTabType('terminal')
+ },
+ { worktreeId: older.worktreeId, tabId: olderTabId }
+ )
+ await waitForActiveTerminalManager(orcaPage, 60_000)
+ await expect
+ .poll(() => getTerminalContent(orcaPage, 20_000), {
+ timeout: 60_000,
+ message: 'revealed evicted worktree did not restore the marker via relay replay'
+ })
+ .toContain(`${olderMarker}:`)
+ } finally {
+ cleanupDockerSshRelayTarget(target)
+ }
+ })
+})
From 4517088c42279fea0dc8c691966ee8973c3be813 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BD=99=E8=BE=89?=
<42717106+OnlyYu1996@users.noreply.github.com>
Date: Wed, 29 Jul 2026 15:56:05 +0800
Subject: [PATCH 22/67] fix(gitlab): refresh self-hosted provider detection
(#9909)
* fix(gitlab): refresh self-hosted provider detection
* fix(gitlab): preserve auth refresh during host probe
* fix(gitlab): merge refreshed auth hosts linearly
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
---
src/main/gitlab/client-mr.test.ts | 35 ++++++++
src/main/gitlab/client.ts | 3 +
src/main/gitlab/gitlab-known-host-probe.ts | 30 +++++--
src/main/gitlab/gl-utils.test.ts | 85 +++++++++++++++++++
...review-creation-gitlab-self-hosted.test.ts | 61 +++++++++++++
5 files changed, 208 insertions(+), 6 deletions(-)
diff --git a/src/main/gitlab/client-mr.test.ts b/src/main/gitlab/client-mr.test.ts
index 91273d7a83f..de8d36494d8 100644
--- a/src/main/gitlab/client-mr.test.ts
+++ b/src/main/gitlab/client-mr.test.ts
@@ -65,6 +65,7 @@ import {
updateMRReviewers
} from './client'
import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch'
+import { _resetKnownHostsCache } from './gitlab-known-host-probe'
/** Answer the real default-branch resolver probes (#9171 guard). */
function primeGitDefaultBranch(defaultRef = 'refs/remotes/origin/main'): void {
@@ -92,6 +93,7 @@ describe('gitlab client — MR operations', () => {
gitExecFileAsyncMock.mockReset()
primeGitDefaultBranch()
__resetRepoDefaultBranchCacheForTests()
+ _resetKnownHostsCache()
_resetGitLabRateLimitCache()
getGlabKnownHostsMock.mockResolvedValue(['gitlab.com'])
resolveIssueSourceMock.mockResolvedValue({
@@ -288,6 +290,39 @@ describe('gitlab client — MR operations', () => {
allowDefaultWslFallback: false
})
})
+
+ it('merges many authenticated hosts with one cache scan', async () => {
+ const hostCount = 256
+ glabExecFileAsyncMock.mockResolvedValueOnce({
+ stdout: Array.from(
+ { length: hostCount },
+ (_, index) => `Logged in to gitlab-${index}.example.test as user`
+ ).join('\n'),
+ stderr: ''
+ })
+ const originalMap = Array.prototype.map
+ let knownHostCacheScans = 0
+ const mapSpy = vi.spyOn(Array.prototype, 'map').mockImplementation(function (
+ this: unknown[],
+ callback: (value: unknown, index: number, array: unknown[]) => unknown,
+ thisArg?: unknown
+ ): unknown[] {
+ if (this[0] === 'gitlab.com' && this.every((value) => typeof value === 'string')) {
+ knownHostCacheScans += 1
+ }
+ return Reflect.apply(originalMap, this, [callback, thisArg])
+ })
+
+ try {
+ await expect(diagnoseAuth()).resolves.toMatchObject({
+ authenticated: true,
+ hosts: expect.any(Array)
+ })
+ } finally {
+ mapSpy.mockRestore()
+ }
+ expect(knownHostCacheScans).toBe(1)
+ })
})
describe('getRateLimit', () => {
diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts
index d2f30b2d531..2ff7f618488 100644
--- a/src/main/gitlab/client.ts
+++ b/src/main/gitlab/client.ts
@@ -39,6 +39,7 @@ import {
type LocalGitExecOptions,
type ProjectRef
} from './gl-utils'
+import { rememberGlabKnownHosts } from './gitlab-known-host-probe'
import type { IssueListState } from './issues'
import {
hasHostedReviewLocalGitOptions,
@@ -100,6 +101,8 @@ export async function diagnoseAuth(): Promise {
})
const output = `${stdout}\n${stderr}`
const hosts = parseGlabAuthStatusHosts(output)
+ // Why: refreshing auth must advance the provider cache key past a stale null result.
+ rememberGlabKnownHosts(hosts)
return {
glabAvailable: true,
authenticated:
diff --git a/src/main/gitlab/gitlab-known-host-probe.ts b/src/main/gitlab/gitlab-known-host-probe.ts
index 1756df4ce96..5699030a4c7 100644
--- a/src/main/gitlab/gitlab-known-host-probe.ts
+++ b/src/main/gitlab/gitlab-known-host-probe.ts
@@ -32,13 +32,30 @@ export function rememberGlabKnownHost(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): void {
- const normalizedHost = normalizeGitLabHost(host)
+ rememberGlabKnownHosts([host], connectionId, localGitOptions)
+}
+
+export function rememberGlabKnownHosts(
+ hosts: readonly string[],
+ connectionId?: string | null,
+ localGitOptions: LocalGitExecOptions = {}
+): void {
const key = knownHostsExecutionKey(connectionId, localGitOptions)
- const cached = knownHostsCacheByExecutionContext.get(key)
- if (!cached || cached.map(normalizeGitLabHost).includes(normalizedHost)) {
+ const cached = knownHostsCacheByExecutionContext.get(key) ?? DEFAULT_GITLAB_HOSTS
+ const seen = new Set(cached.map(normalizeGitLabHost))
+ const additions: string[] = []
+ for (const host of hosts) {
+ const normalizedHost = normalizeGitLabHost(host)
+ if (seen.has(normalizedHost)) {
+ continue
+ }
+ seen.add(normalizedHost)
+ additions.push(normalizedHost)
+ }
+ if (additions.length === 0) {
return
}
- knownHostsCacheByExecutionContext.set(key, [...cached, normalizedHost])
+ knownHostsCacheByExecutionContext.set(key, [...cached, ...additions])
}
export async function getGlabKnownHosts(
@@ -80,12 +97,13 @@ async function probeGlabKnownHosts(
: {})
})
const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`)
- const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts]))
+ const remembered = knownHostsCacheByExecutionContext.get(key) ?? []
+ const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...remembered, ...hosts]))
knownHostsCacheByExecutionContext.set(key, merged)
return merged
} catch {
// Keep failures uncached so auth or tunnel recovery is discovered later.
- return [...DEFAULT_GITLAB_HOSTS]
+ return knownHostsCacheByExecutionContext.get(key) ?? [...DEFAULT_GITLAB_HOSTS]
}
}
diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts
index 9ab99ddc441..21f483ca0f5 100644
--- a/src/main/gitlab/gl-utils.test.ts
+++ b/src/main/gitlab/gl-utils.test.ts
@@ -25,6 +25,7 @@ import {
parseGlabAuthStatusHosts,
resolveIssueSource
} from './gl-utils'
+import { rememberGlabKnownHost, rememberGlabKnownHosts } from './gitlab-known-host-probe'
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
describe('gitlab project ref resolution', () => {
@@ -503,6 +504,90 @@ describe('getGlabKnownHosts', () => {
})
})
+ it('preserves a native auth refresh while an older native probe is in flight', async () => {
+ let resolveProbe!: (value: { stdout: string; stderr: string }) => void
+ glabExecFileAsyncMock.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveProbe = resolve
+ })
+ )
+
+ const staleProbe = getGlabKnownHosts()
+ rememberGlabKnownHost('gitlab.refreshed.test')
+ resolveProbe({ stdout: 'Logged in to gitlab.com as user\n', stderr: '' })
+
+ await expect(staleProbe).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
+ await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
+ })
+
+ it('preserves a native auth refresh when an older native probe fails', async () => {
+ let rejectProbe!: (error: Error) => void
+ glabExecFileAsyncMock.mockImplementationOnce(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectProbe = reject
+ })
+ )
+
+ const staleProbe = getGlabKnownHosts()
+ rememberGlabKnownHost('gitlab.refreshed.test')
+ rejectProbe(new Error('stale auth probe failed'))
+
+ await expect(staleProbe).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
+ await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
+ })
+
+ it('keeps a remembered native host out of WSL and SSH caches', async () => {
+ glabExecFileAsyncMock
+ .mockResolvedValueOnce({ stdout: 'Logged in to native.test as user\n', stderr: '' })
+ .mockResolvedValueOnce({ stdout: 'Logged in to wsl.test as user\n', stderr: '' })
+ .mockResolvedValueOnce({ stdout: 'Logged in to ssh.test as user\n', stderr: '' })
+
+ await Promise.all([
+ getGlabKnownHosts(),
+ getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }),
+ getGlabKnownHosts('conn-1')
+ ])
+ rememberGlabKnownHost('gitlab.refreshed.test')
+
+ await expect(getGlabKnownHosts()).resolves.toEqual([
+ 'gitlab.com',
+ 'native.test',
+ 'gitlab.refreshed.test'
+ ])
+ await expect(getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })).resolves.toEqual([
+ 'gitlab.com',
+ 'wsl.test'
+ ])
+ await expect(getGlabKnownHosts('conn-1')).resolves.toEqual(['gitlab.com', 'ssh.test'])
+ })
+
+ it('batch-normalizes and deduplicates hosts in first-seen order per execution context', async () => {
+ rememberGlabKnownHosts([' Native-B.test ', 'native-a.test', 'NATIVE-B.TEST'])
+ rememberGlabKnownHosts(['WSL-B.test', ' wsl-a.test ', 'wsl-b.test'], undefined, {
+ wslDistro: 'Ubuntu'
+ })
+ rememberGlabKnownHosts(['SSH-B.test', 'ssh-a.test', ' ssh-b.test '], 'conn-batch')
+
+ await expect(getGlabKnownHosts()).resolves.toEqual([
+ 'gitlab.com',
+ 'native-b.test',
+ 'native-a.test'
+ ])
+ await expect(getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })).resolves.toEqual([
+ 'gitlab.com',
+ 'wsl-b.test',
+ 'wsl-a.test'
+ ])
+ await expect(getGlabKnownHosts('conn-batch')).resolves.toEqual([
+ 'gitlab.com',
+ 'ssh-b.test',
+ 'ssh-a.test'
+ ])
+ expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
+ })
+
it('recognizes a self-hosted host on a non-default port', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
diff --git a/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts b/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts
index c801d7db454..2bb808857ef 100644
--- a/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts
+++ b/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts
@@ -62,6 +62,7 @@ vi.mock('./hosted-review', () => ({
}))
import { _resetKnownHostsCache, _resetProjectRefCache } from '../gitlab/gl-utils'
+import { diagnoseAuth } from '../gitlab/client'
import { getHostedReviewCreationEligibility } from './hosted-review-creation'
function resetMocks(): void {
@@ -190,4 +191,64 @@ gitlab.internal
nextAction: 'authenticate'
})
})
+
+ it('recovers provider detection after auth diagnostics discover a rejected host', async () => {
+ let selfHostedAuthenticated = false
+ getGiteaRepoSlugMock.mockResolvedValue({
+ host: 'gitlab.example.com',
+ owner: 'team',
+ repo: 'orca',
+ apiBaseUrl: 'https://gitlab.example.com/api/v1',
+ webBaseUrl: 'https://gitlab.example.com'
+ })
+ gitExecFileAsyncMock.mockResolvedValue({
+ stdout: 'https://gitlab.example.com/team/orca.git\n',
+ stderr: ''
+ })
+ glabExecFileAsyncMock.mockImplementation(async (args: string[]) => {
+ if (args.includes('--hostname')) {
+ if (!selfHostedAuthenticated) {
+ throw new Error('authentication required for gitlab.example.com')
+ }
+ return {
+ stdout: '✓ Logged in to gitlab.example.com as user\n',
+ stderr: ''
+ }
+ }
+ return selfHostedAuthenticated
+ ? {
+ stdout: '✓ Logged in to gitlab.example.com as user\n',
+ stderr: ''
+ }
+ : {
+ stdout: '✓ Logged in to gitlab.com as user\n',
+ stderr: ''
+ }
+ })
+
+ const eligibilityInput = {
+ repoPath: '/repo',
+ branch: 'feature/self-hosted-mr',
+ base: 'main',
+ hasUncommittedChanges: false,
+ hasUpstream: true,
+ ahead: 0,
+ behind: 0
+ }
+ await expect(getHostedReviewCreationEligibility(eligibilityInput)).resolves.toMatchObject({
+ provider: 'gitea',
+ blockedReason: 'auth_required'
+ })
+
+ selfHostedAuthenticated = true
+ await expect(diagnoseAuth()).resolves.toMatchObject({
+ authenticated: true,
+ hosts: ['gitlab.example.com']
+ })
+ await expect(getHostedReviewCreationEligibility(eligibilityInput)).resolves.toMatchObject({
+ provider: 'gitlab',
+ canCreate: true,
+ blockedReason: null
+ })
+ })
})
From fa449bc0ef5301a64bcd0357605819b5fcfa0256 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Wed, 29 Jul 2026 00:56:26 -0700
Subject: [PATCH 23/67] fix(worktree-palette): stop blanked display names from
crashing Cmd+J (#11323)
* fix(worktree-palette): stop blanked display names from crashing Cmd+J
Blanking the "Display Name" field made buildWorktreeMetaUpdates emit
`displayName: undefined` as a present key. The store's `{ ...worktree,
...updates }` spread then erased the live name, so the next palette
keystroke threw "Cannot read properties of undefined (reading
'toLowerCase')" in searchWorktrees (crash a1f81ea1, build 1.4.159).
Fixed at three layers so no single guard is load-bearing:
- Producer: persist the blanking intent as '' instead of undefined, and
let WorktreeSet accept '' so remote/SSH hosts stop dropping the clear.
- Store: applyWorktreeUpdates and applyDetectedWorktreeUpdates drop
present-but-undefined keys for fields Worktree declares required.
- Readers: resolveWorktreeDisplayName/resolveWorktreeBranchLabel mirror
the main-side mergeWorktree fallback (custom -> branch -> folder) for
all four Cmd+J searches, the checks/review index, and the render site.
Co-authored-by: Orca
* test(worktree): assert omitted display name shape
---------
Co-authored-by: Orca
---
.../rpc/methods/worktree-schemas.test.ts | 25 ++++-
.../runtime/rpc/methods/worktree-schemas.ts | 5 +-
.../src/components/WorktreeJumpPalette.tsx | 14 ++-
.../worktree-checks-review-index.test.ts | 21 ++++
.../cmd-j/worktree-checks-review-index.ts | 6 +-
.../sidebar/worktree-meta-updates.test.ts | 13 +++
.../sidebar/worktree-meta-updates.ts | 8 +-
.../src/lib/browser-palette-search.test.ts | 50 ++++++++++
.../src/lib/browser-palette-search.ts | 7 +-
.../src/lib/simulator-palette-search.test.ts | 45 +++++++++
.../src/lib/simulator-palette-search.ts | 7 +-
.../src/lib/workspace-tab-palette-results.ts | 7 +-
.../lib/workspace-tab-palette-search.test.ts | 32 +++++++
.../lib/worktree-default-display-name.test.ts | 95 +++++++++++++++++++
.../src/lib/worktree-default-display-name.ts | 34 +++++++
.../src/lib/worktree-palette-search.test.ts | 57 +++++++++++
.../src/lib/worktree-palette-search.ts | 11 ++-
.../src/store/slices/worktree-helpers.test.ts | 17 ++++
.../src/store/slices/worktree-helpers.ts | 42 +++++++-
src/renderer/src/store/slices/worktrees.ts | 5 +-
20 files changed, 478 insertions(+), 23 deletions(-)
create mode 100644 src/renderer/src/lib/worktree-default-display-name.test.ts
create mode 100644 src/renderer/src/lib/worktree-default-display-name.ts
diff --git a/src/main/runtime/rpc/methods/worktree-schemas.test.ts b/src/main/runtime/rpc/methods/worktree-schemas.test.ts
index 975693bb7f7..219791cb9cc 100644
--- a/src/main/runtime/rpc/methods/worktree-schemas.test.ts
+++ b/src/main/runtime/rpc/methods/worktree-schemas.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { WorktreeActivate, WorktreeCreate } from './worktree-schemas'
+import { WorktreeActivate, WorktreeCreate, WorktreeSet } from './worktree-schemas'
describe('worktree RPC schemas', () => {
it('validates additive navigation intent', () => {
@@ -31,4 +31,27 @@ describe('worktree RPC schemas', () => {
expect(parsed.success).toBe(false)
})
+
+ it('keeps a blanked display name on remote hosts instead of dropping the clear', () => {
+ // Blanking sends displayName:'' meaning "fall back to the branch/folder name".
+ // Coercing it to undefined made updateManagedWorktreeMeta's omitUndefinedProperties
+ // drop the key, so an SSH/paired-web rename-to-blank silently kept the old name.
+ const parsed = WorktreeSet.parse({ worktree: 'id:r1::/repos/wt', displayName: '' })
+
+ expect(parsed.displayName).toBe('')
+ expect(Object.prototype.hasOwnProperty.call(parsed, 'displayName')).toBe(true)
+ })
+
+ it('still omits a display name that was never sent', () => {
+ const parsed = WorktreeSet.parse({ worktree: 'id:r1::/repos/wt', comment: 'note' })
+
+ expect(parsed.displayName).toBeUndefined()
+ expect(Object.prototype.hasOwnProperty.call(parsed, 'displayName')).toBe(false)
+ })
+
+ it('ignores a non-string display name rather than persisting it', () => {
+ const parsed = WorktreeSet.parse({ worktree: 'id:r1::/repos/wt', displayName: 42 })
+
+ expect(parsed.displayName).toBeUndefined()
+ })
})
diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts
index b79fb320f59..905a58f95fc 100644
--- a/src/main/runtime/rpc/methods/worktree-schemas.ts
+++ b/src/main/runtime/rpc/methods/worktree-schemas.ts
@@ -189,7 +189,10 @@ export const WorktreePrefetchCreateBase = z.object({
})
export const WorktreeSet = WorktreeSelector.extend({
- displayName: OptionalString,
+ // Why: '' is the blanking contract — "fall back to the branch/folder name".
+ // OptionalString coerced it to undefined, so on remote/SSH hosts clearing the
+ // name was dropped here and the old name came back on the next refresh.
+ displayName: OptionalPlainString,
// Why: empty comments are meaningful metadata updates, so use the plain
// string parser instead of OptionalString's empty-as-undefined behavior.
comment: OptionalPlainString,
diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx
index 61f3a4eb52c..efe91e4e7bc 100644
--- a/src/renderer/src/components/WorktreeJumpPalette.tsx
+++ b/src/renderer/src/components/WorktreeJumpPalette.tsx
@@ -23,7 +23,6 @@ import {
CommandEmpty,
CommandItem
} from '@/components/ui/command'
-import { branchName } from '@/lib/git-utils'
import { parseGitHubIssueOrPRNumber, parseGitHubIssueOrPRLink } from '@/lib/github-links'
import { getLinkedWorkItemSuggestedName, getLinkedWorkItemWorkspaceName } from '@/lib/new-workspace'
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
@@ -48,6 +47,10 @@ import {
type MatchRange,
type PaletteSearchResult
} from '@/lib/worktree-palette-search'
+import {
+ resolveWorktreeBranchLabel,
+ resolveWorktreeDisplayName
+} from '@/lib/worktree-default-display-name'
import {
CREATE_WORKTREE_ITEM_ID,
createWorktreePaletteRequestGuard,
@@ -1794,7 +1797,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const worktree = entry.worktree
const repo = repoMap.get(worktree.repoId)
const repoName = repo?.displayName ?? ''
- const branch = branchName(worktree.branch)
+ // Why: both must match searchWorktrees' resolution, or highlight ranges land on
+ // the wrong text — and a branch-less row would throw here before search ever ran.
+ const branch = resolveWorktreeBranchLabel(worktree)
+ const worktreeLabel = resolveWorktreeDisplayName(worktree)
const status = getWorktreeStatus(
tabsByWorktree[worktree.id] ?? [],
browserTabsByWorktree[worktree.id] ?? [],
@@ -1862,11 +1868,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
{entry.match.displayNameRange ? (
) : (
- worktree.displayName
+ worktreeLabel
)}
{isCurrentWorktree && (
diff --git a/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts b/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts
index 78889b1d2db..e401aa0665b 100644
--- a/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts
+++ b/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts
@@ -177,4 +177,25 @@ describe('buildWorktreeChecksReviewIndex', () => {
expect(reviews.has(localWorktree)).toBe(false)
expect(reviews.get(sshWorktree)).toMatchObject({ provider: 'github', number: 42 })
})
+
+ it('skips a branch-less worktree instead of throwing', () => {
+ // Why: Cmd+J builds this index for every worktree before any query is typed,
+ // so a folder workspace (empty branch) or a partially hydrated row reaches it.
+ const branchless: Worktree = {
+ ...worktree,
+ id: 'worktree-folder',
+ branch: undefined as unknown as string,
+ displayName: undefined as unknown as string
+ }
+
+ const reviews = buildWorktreeChecksReviewIndex({
+ worktrees: [branchless],
+ repoByHostIdentity: new Map([[getRepoHostIdentity(repo), repo]]),
+ prCache: {},
+ hostedReviewCache: {},
+ settings: null
+ })
+
+ expect(reviews.has(branchless)).toBe(false)
+ })
})
diff --git a/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts b/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts
index 507eee5543d..992fab427a9 100644
--- a/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts
+++ b/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts
@@ -1,4 +1,4 @@
-import { branchName } from '@/lib/git-utils'
+import { resolveWorktreeBranchLabel } from '@/lib/worktree-default-display-name'
import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity'
import { getRepoHostIdentityForParts } from '@/store/slices/repo-host-identity'
@@ -35,7 +35,9 @@ export function buildWorktreeChecksReviewIndex({
if (!repo) {
continue
}
- const branch = branchName(worktree.branch)
+ // Why: Cmd+J builds this index for every worktree before search runs, so a
+ // branch-less folder workspace or partially hydrated row must not throw here.
+ const branch = resolveWorktreeBranchLabel(worktree)
const prKey = getGitHubPRCacheKey(
repo.path,
repo.id,
diff --git a/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts b/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts
index a53eeae4163..e7e81727b07 100644
--- a/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts
@@ -49,6 +49,19 @@ describe('buildWorktreeMetaUpdates', () => {
})
})
+ it('clears a display name with empty string, never a present-undefined key', () => {
+ const updates = buildWorktreeMetaUpdates({
+ displayNameInput: ' ',
+ currentDisplayName: 'Custom Name',
+ issueInput: '',
+ prInput: '',
+ commentInput: ''
+ })
+
+ expect(updates.displayName).toBe('')
+ expect(Object.values(updates).every((value) => value !== undefined)).toBe(true)
+ })
+
it('rejects PR URLs in the issue input', () => {
expect(
buildWorktreeMetaUpdates({
diff --git a/src/renderer/src/components/sidebar/worktree-meta-updates.ts b/src/renderer/src/components/sidebar/worktree-meta-updates.ts
index 26769c65061..538b8de6b3a 100644
--- a/src/renderer/src/components/sidebar/worktree-meta-updates.ts
+++ b/src/renderer/src/components/sidebar/worktree-meta-updates.ts
@@ -48,12 +48,14 @@ export function buildWorktreeMetaUpdates(args: {
const finalLinkedPR =
trimmedPR === '' ? null : linkedPRNumber !== null ? linkedPRNumber : undefined
+ // Why: blanking the field means "fall back to the branch/folder name", and the
+ // empty string is how that intent is persisted. Emitting `undefined` instead
+ // put a present-but-undefined key into the store spread, wiping the live name
+ // and crashing the worktree palette (crash a1f81ea1).
const trimmedDisplayName = args.displayNameInput.trim()
const updates: Partial = {
comment: args.commentInput.trim(),
- ...(trimmedDisplayName !== args.currentDisplayName && {
- displayName: trimmedDisplayName || undefined
- })
+ ...(trimmedDisplayName !== args.currentDisplayName && { displayName: trimmedDisplayName })
}
if (finalLinkedIssue !== undefined) {
updates.linkedIssue = finalLinkedIssue
diff --git a/src/renderer/src/lib/browser-palette-search.test.ts b/src/renderer/src/lib/browser-palette-search.test.ts
index 6e76c9ab583..23cf0196984 100644
--- a/src/renderer/src/lib/browser-palette-search.test.ts
+++ b/src/renderer/src/lib/browser-palette-search.test.ts
@@ -331,4 +331,54 @@ describe('browser-palette-search', () => {
it('rejects oversized whitespace before trimming', () => {
expect(searchBrowserPages([], ' '.repeat(BROWSER_PALETTE_QUERY_MAX_BYTES + 1))).toEqual([])
})
+
+ it('falls back to the branch label when a cleared display name left it undefined', () => {
+ // Why: Cmd+J runs this search over the same worktree objects as searchWorktrees,
+ // so the store-level display-name corruption reaches here too.
+ const cleared = makeWorktree({
+ displayName: undefined as unknown as string,
+ branch: 'refs/heads/feature/browser-search'
+ })
+ const entries: SearchableBrowserPage[] = [
+ {
+ page: makePage(),
+ workspace: makeWorkspace(),
+ worktree: cleared,
+ repoName: 'orca',
+ worktreeSortIndex: 0,
+ isCurrentPage: false,
+ isCurrentWorktree: false
+ }
+ ]
+
+ const results = searchBrowserPages(entries, 'browser-search')
+ expect(results[0]).toMatchObject({
+ worktreeName: 'feature/browser-search',
+ worktreeRange: { start: 'feature/'.length, end: 'feature/browser-search'.length }
+ })
+ })
+
+ it('lists a branch-less row on the empty query without throwing', () => {
+ const cleared = makeWorktree({
+ displayName: undefined as unknown as string,
+ branch: undefined as unknown as string,
+ path: '/repos/design-review'
+ })
+ const entries: SearchableBrowserPage[] = [
+ {
+ page: makePage(),
+ workspace: makeWorkspace(),
+ worktree: cleared,
+ repoName: 'orca',
+ worktreeSortIndex: 0,
+ isCurrentPage: false,
+ isCurrentWorktree: false
+ }
+ ]
+
+ expect(searchBrowserPages(entries, '')[0]).toMatchObject({
+ worktreeName: 'design-review',
+ worktreeRange: null
+ })
+ })
})
diff --git a/src/renderer/src/lib/browser-palette-search.ts b/src/renderer/src/lib/browser-palette-search.ts
index 759335a4aa8..94233164037 100644
--- a/src/renderer/src/lib/browser-palette-search.ts
+++ b/src/renderer/src/lib/browser-palette-search.ts
@@ -1,6 +1,7 @@
import { ORCA_BROWSER_BLANK_URL } from '../../../shared/constants'
import type { BrowserPage, BrowserWorkspace, Worktree } from '../../../shared/types'
import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text'
+import { resolveWorktreeDisplayName } from './worktree-default-display-name'
import type { MatchRange } from './worktree-palette-search'
export type SearchableBrowserPage = {
@@ -125,6 +126,8 @@ export function searchBrowserPages(
const formattedUrl = formatBrowserPaletteUrl(entry.page.url)
const title = entry.page.title || formattedUrl
const fallbackSecondaryText = formattedUrl
+ // Why: a cleared display name leaves this undefined at runtime; findRange would throw.
+ const worktreeName = resolveWorktreeDisplayName(entry.worktree)
const baseResult = {
pageId: entry.page.id,
workspaceId: entry.workspace.id,
@@ -132,7 +135,7 @@ export function searchBrowserPages(
title,
workspaceLabel: entry.workspace.label ?? null,
repoName: entry.repoName,
- worktreeName: entry.worktree.displayName,
+ worktreeName,
isCurrentPage: entry.isCurrentPage,
isCurrentWorktree: entry.isCurrentWorktree
}
@@ -234,7 +237,7 @@ export function searchBrowserPages(
continue
}
- const worktreeRange = findRange(entry.worktree.displayName, trimmedQuery)
+ const worktreeRange = findRange(worktreeName, trimmedQuery)
if (worktreeRange) {
results.push({
...baseResult,
diff --git a/src/renderer/src/lib/simulator-palette-search.test.ts b/src/renderer/src/lib/simulator-palette-search.test.ts
index 48b7ea98d6e..cfc8aa4a720 100644
--- a/src/renderer/src/lib/simulator-palette-search.test.ts
+++ b/src/renderer/src/lib/simulator-palette-search.test.ts
@@ -174,4 +174,49 @@ describe('simulator-palette-search', () => {
it('rejects oversized whitespace before trimming simulator palette queries', () => {
expect(searchSimulatorTabs([], ' '.repeat(SIMULATOR_PALETTE_QUERY_MAX_BYTES + 1))).toEqual([])
})
+
+ it('falls back to the branch label when a cleared display name left it undefined', () => {
+ // Why: Cmd+J runs this search over the same worktree objects as searchWorktrees,
+ // so the store-level display-name corruption reaches here too.
+ const entries = [
+ {
+ tab: makeTab(),
+ worktree: makeWorktree({
+ displayName: undefined as unknown as string,
+ branch: 'refs/heads/feature/mobile-emulator'
+ }),
+ repoName: 'orca',
+ worktreeSortIndex: 0,
+ isCurrentTab: false,
+ isCurrentWorktree: false
+ }
+ ]
+
+ expect(searchSimulatorTabs(entries, 'mobile-emulator')[0]).toMatchObject({
+ worktreeName: 'feature/mobile-emulator',
+ worktreeRange: { start: 'feature/'.length, end: 'feature/mobile-emulator'.length }
+ })
+ })
+
+ it('lists a branch-less row on the empty query without throwing', () => {
+ const entries = [
+ {
+ tab: makeTab(),
+ worktree: makeWorktree({
+ displayName: undefined as unknown as string,
+ branch: undefined as unknown as string,
+ path: '/repos/design-review'
+ }),
+ repoName: 'orca',
+ worktreeSortIndex: 0,
+ isCurrentTab: false,
+ isCurrentWorktree: false
+ }
+ ]
+
+ expect(searchSimulatorTabs(entries, '')[0]).toMatchObject({
+ worktreeName: 'design-review',
+ worktreeRange: null
+ })
+ })
})
diff --git a/src/renderer/src/lib/simulator-palette-search.ts b/src/renderer/src/lib/simulator-palette-search.ts
index 1260700ca41..aded934662e 100644
--- a/src/renderer/src/lib/simulator-palette-search.ts
+++ b/src/renderer/src/lib/simulator-palette-search.ts
@@ -1,5 +1,6 @@
import type { Tab, TabGroup, Worktree } from '../../../shared/types'
import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text'
+import { resolveWorktreeDisplayName } from './worktree-default-display-name'
import type { MatchRange } from './worktree-palette-search'
export type SearchableSimulatorTab = {
@@ -180,6 +181,8 @@ export function searchSimulatorTabs(
for (const entry of entries) {
const title = entry.tab.label || 'Mobile Emulator'
const secondaryText = 'Mobile Emulator tab'
+ // Why: a cleared display name leaves this undefined at runtime; findRange would throw.
+ const worktreeName = resolveWorktreeDisplayName(entry.worktree)
const baseResult = {
tabId: entry.tab.id,
worktreeId: entry.worktree.id,
@@ -187,7 +190,7 @@ export function searchSimulatorTabs(
title,
secondaryText,
repoName: entry.repoName,
- worktreeName: entry.worktree.displayName,
+ worktreeName,
isCurrentTab: entry.isCurrentTab,
isCurrentWorktree: entry.isCurrentWorktree
}
@@ -253,7 +256,7 @@ export function searchSimulatorTabs(
continue
}
- const worktreeRange = findRange(entry.worktree.displayName, trimmedQuery)
+ const worktreeRange = findRange(worktreeName, trimmedQuery)
if (worktreeRange) {
results.push({
...baseResult,
diff --git a/src/renderer/src/lib/workspace-tab-palette-results.ts b/src/renderer/src/lib/workspace-tab-palette-results.ts
index 4f1a5798e43..9d829355a43 100644
--- a/src/renderer/src/lib/workspace-tab-palette-results.ts
+++ b/src/renderer/src/lib/workspace-tab-palette-results.ts
@@ -1,3 +1,4 @@
+import { resolveWorktreeDisplayName } from './worktree-default-display-name'
import type { MatchRange } from './worktree-palette-search'
import type {
SearchableWorkspaceTab,
@@ -114,6 +115,8 @@ export function searchWorkspaceTabs(
const results: WorkspaceTabPaletteSearchResult[] = []
for (const entry of entries) {
+ // Why: a cleared display name leaves this undefined at runtime; findRange would throw.
+ const worktreeName = resolveWorktreeDisplayName(entry.worktree)
const baseResult = {
tabId: entry.tab.id,
entityId: entry.tab.entityId,
@@ -123,7 +126,7 @@ export function searchWorkspaceTabs(
title: entry.title,
secondaryText: entry.secondaryText,
repoName: entry.repoName,
- worktreeName: entry.worktree.displayName,
+ worktreeName,
isCurrentTab: entry.isCurrentTab,
isCurrentWorktree: entry.isCurrentWorktree
}
@@ -200,7 +203,7 @@ export function searchWorkspaceTabs(
continue
}
- const worktreeRange = findRange(entry.worktree.displayName, trimmedQuery)
+ const worktreeRange = findRange(worktreeName, trimmedQuery)
if (worktreeRange) {
results.push({
...baseResult,
diff --git a/src/renderer/src/lib/workspace-tab-palette-search.test.ts b/src/renderer/src/lib/workspace-tab-palette-search.test.ts
index fb02f8c749f..03b0f55fa42 100644
--- a/src/renderer/src/lib/workspace-tab-palette-search.test.ts
+++ b/src/renderer/src/lib/workspace-tab-palette-search.test.ts
@@ -438,4 +438,36 @@ describe('workspace-tab-palette-search', () => {
'terminal-other'
])
})
+
+ it('falls back to the branch label when a cleared display name left it undefined', () => {
+ // Why: Cmd+J runs this search over the same worktree objects as searchWorktrees,
+ // so the store-level display-name corruption reaches here too.
+ const cleared = makeWorktree({
+ displayName: undefined as unknown as string,
+ branch: 'refs/heads/feature/workspace-tab-search'
+ })
+ const entries = buildEntries({ worktrees: [cleared] })
+
+ expect(searchWorkspaceTabs(entries, 'workspace-tab-search')[0]).toMatchObject({
+ worktreeName: 'feature/workspace-tab-search',
+ worktreeRange: {
+ start: 'feature/'.length,
+ end: 'feature/workspace-tab-search'.length
+ }
+ })
+ })
+
+ it('lists a branch-less row on the empty query without throwing', () => {
+ const cleared = makeWorktree({
+ displayName: undefined as unknown as string,
+ branch: undefined as unknown as string,
+ path: path.join('repos', 'design-review')
+ })
+ const entries = buildEntries({ worktrees: [cleared] })
+
+ expect(searchWorkspaceTabs(entries, '')[0]).toMatchObject({
+ worktreeName: 'design-review',
+ worktreeRange: null
+ })
+ })
})
diff --git a/src/renderer/src/lib/worktree-default-display-name.test.ts b/src/renderer/src/lib/worktree-default-display-name.test.ts
new file mode 100644
index 00000000000..89184c42123
--- /dev/null
+++ b/src/renderer/src/lib/worktree-default-display-name.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, it } from 'vitest'
+import {
+ resolveWorktreeBranchLabel,
+ resolveWorktreeDisplayName
+} from './worktree-default-display-name'
+
+describe('resolveWorktreeBranchLabel', () => {
+ it('strips refs/heads/ like the raw branchName call it replaces', () => {
+ expect(resolveWorktreeBranchLabel({ branch: 'refs/heads/feature/jump' })).toBe('feature/jump')
+ })
+
+ it('returns empty for a folder workspace, which carries no branch', () => {
+ expect(resolveWorktreeBranchLabel({ branch: '' })).toBe('')
+ })
+
+ it('returns empty instead of throwing when branch is absent at runtime', () => {
+ // The palette renders every row on an empty query, before any branch search runs,
+ // so an unguarded branchName() here crashed the whole palette.
+ expect(resolveWorktreeBranchLabel({ branch: undefined as unknown as string })).toBe('')
+ })
+})
+
+describe('resolveWorktreeDisplayName', () => {
+ it('prefers the custom name', () => {
+ expect(
+ resolveWorktreeDisplayName({
+ displayName: 'Design review',
+ branch: 'refs/heads/feature/jump',
+ path: '/repos/orca'
+ })
+ ).toBe('Design review')
+ })
+
+ it('falls back to the branch when the name was blanked to an empty string', () => {
+ expect(
+ resolveWorktreeDisplayName({
+ displayName: '',
+ branch: 'refs/heads/feature/jump',
+ path: '/repos/orca'
+ })
+ ).toBe('feature/jump')
+ })
+
+ it('treats a whitespace-only name as blank', () => {
+ expect(
+ resolveWorktreeDisplayName({
+ displayName: ' ',
+ branch: 'refs/heads/main',
+ path: '/repos/orca'
+ })
+ ).toBe('main')
+ })
+
+ it('falls back to the branch when a cleared name left the field undefined', () => {
+ expect(
+ resolveWorktreeDisplayName({
+ displayName: undefined as unknown as string,
+ branch: 'refs/heads/main',
+ path: '/repos/orca'
+ })
+ ).toBe('main')
+ })
+
+ it('falls back to the folder name for a branch-less folder workspace', () => {
+ expect(
+ resolveWorktreeDisplayName({ displayName: '', branch: '', path: '/repos/design-review' })
+ ).toBe('design-review')
+ })
+
+ it('resolves the folder name from a Windows path', () => {
+ expect(
+ resolveWorktreeDisplayName({
+ displayName: '',
+ branch: '',
+ path: 'C:\\Users\\alice\\repos\\design-review'
+ })
+ ).toBe('design-review')
+ })
+
+ it('keeps emoji and non-ASCII names intact', () => {
+ expect(
+ resolveWorktreeDisplayName({ displayName: '🚀 Läufer', branch: '', path: '/repos/x' })
+ ).toBe('🚀 Läufer')
+ })
+
+ it('returns empty rather than throwing when every source is missing', () => {
+ expect(
+ resolveWorktreeDisplayName({
+ displayName: undefined as unknown as string,
+ branch: undefined as unknown as string,
+ path: undefined as unknown as string
+ })
+ ).toBe('')
+ })
+})
diff --git a/src/renderer/src/lib/worktree-default-display-name.ts b/src/renderer/src/lib/worktree-default-display-name.ts
new file mode 100644
index 00000000000..765b6d084a1
--- /dev/null
+++ b/src/renderer/src/lib/worktree-default-display-name.ts
@@ -0,0 +1,34 @@
+import { branchName } from '@/lib/git-utils'
+import { basename } from '@/lib/path'
+import type { Worktree } from '../../../shared/types'
+
+type WorktreeDisplayNameSource = Pick
+
+/**
+ * `branch` is typed non-optional but is absent on folder workspaces and
+ * partially hydrated rows, and `branchName` throws on undefined. Every render
+ * and search read of the branch label must go through here.
+ */
+export function resolveWorktreeBranchLabel(worktree: Pick): string {
+ return typeof worktree.branch === 'string' ? branchName(worktree.branch) : ''
+}
+
+/**
+ * Renderer mirror of main-side `mergeWorktree`: a missing or blank custom name
+ * falls back to the branch, then the folder. `displayName` is typed non-optional
+ * but arrives undefined at runtime once a custom name is cleared (crash
+ * a1f81ea1), so every read must go through here instead of dereferencing it.
+ */
+export function resolveWorktreeDisplayName(worktree: WorktreeDisplayNameSource): string {
+ const custom = typeof worktree.displayName === 'string' ? worktree.displayName.trim() : ''
+ if (custom) {
+ return custom
+ }
+
+ const branch = resolveWorktreeBranchLabel(worktree).trim()
+ if (branch) {
+ return branch
+ }
+
+ return typeof worktree.path === 'string' ? basename(worktree.path).trim() : ''
+}
diff --git a/src/renderer/src/lib/worktree-palette-search.test.ts b/src/renderer/src/lib/worktree-palette-search.test.ts
index 4cb69b75e77..81d549a346c 100644
--- a/src/renderer/src/lib/worktree-palette-search.test.ts
+++ b/src/renderer/src/lib/worktree-palette-search.test.ts
@@ -141,6 +141,63 @@ describe('worktree-palette-search', () => {
expect(searchWorktrees([makeWorktree()], query, repoMap, null, null)).toEqual([])
})
+ it('falls back to branch text when a cleared display name left it undefined', () => {
+ const cleared = makeWorktree({
+ displayName: undefined as unknown as string,
+ branch: 'refs/heads/feature/worktree-jump'
+ })
+
+ expect(() => searchWorktrees([cleared], 'jump', repoMap, null, null)).not.toThrow()
+ // Highlight range indexes the branch-derived label the palette actually renders.
+ expect(searchWorktrees([cleared], 'jump', repoMap, null, null)[0]).toMatchObject({
+ worktreeId: 'wt-1',
+ matchedField: 'displayName',
+ displayNameRange: { start: 'feature/worktree-'.length, end: 'feature/worktree-jump'.length }
+ })
+ })
+
+ it('falls back to the folder name when both display name and branch are missing', () => {
+ const folderWorkspace = makeWorktree({
+ displayName: undefined as unknown as string,
+ branch: '',
+ path: '/tmp/design-review'
+ })
+
+ expect(searchWorktrees([folderWorkspace], 'design', repoMap, null, null)[0]).toMatchObject({
+ matchedField: 'displayName',
+ displayNameRange: { start: 0, end: 6 }
+ })
+ })
+
+ it('survives a cleared display name on composite repo/branch queries', () => {
+ const cleared = makeWorktree({
+ displayName: undefined as unknown as string,
+ branch: undefined as unknown as string
+ })
+
+ expect(() => searchWorktrees([cleared], 'orca/jump', repoMap, null, null)).not.toThrow()
+ })
+
+ it('still lists a branch-less row on the empty query, which renders every row', () => {
+ // Why: the empty query short-circuits before any branch read, so the row reaches the
+ // render loop untouched — the label resolution there has to be guarded too.
+ const cleared = makeWorktree({
+ displayName: undefined as unknown as string,
+ branch: undefined as unknown as string
+ })
+
+ expect(searchWorktrees([cleared], '', repoMap, null, null)).toEqual([
+ {
+ worktreeId: 'wt-1',
+ matchedField: null,
+ displayNameRange: null,
+ branchRange: null,
+ repoRange: null,
+ supportingText: null
+ }
+ ])
+ })
+
it('returns a truncated comment snippet with the highlighted match range', () => {
const results = searchWorktrees(
[
diff --git a/src/renderer/src/lib/worktree-palette-search.ts b/src/renderer/src/lib/worktree-palette-search.ts
index f8dce99582b..074587e8aec 100644
--- a/src/renderer/src/lib/worktree-palette-search.ts
+++ b/src/renderer/src/lib/worktree-palette-search.ts
@@ -1,5 +1,8 @@
-import { branchName } from '@/lib/git-utils'
import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github'
+import {
+ resolveWorktreeBranchLabel,
+ resolveWorktreeDisplayName
+} from './worktree-default-display-name'
import type { HostedReviewInfo } from '../../../shared/hosted-review'
import type { Repo, Worktree } from '../../../shared/types'
import { extractWorktreePaletteCommentSnippet } from './worktree-palette-comment-snippet'
@@ -101,7 +104,7 @@ export function searchWorktrees(
for (const worktree of worktrees) {
if (composite) {
const repoName = repoMap.get(worktree.repoId)?.displayName ?? ''
- const branch = branchName(worktree.branch)
+ const branch = resolveWorktreeBranchLabel(worktree)
const repoIdx = repoName.toLowerCase().indexOf(composite.repoPart)
const branchIdx = branch.toLowerCase().indexOf(composite.branchPart)
if (repoIdx !== -1 && branchIdx !== -1) {
@@ -117,7 +120,7 @@ export function searchWorktrees(
// that happens to contain a slash (e.g. "feature/foo") still get hits.
}
- const nameIndex = worktree.displayName.toLowerCase().indexOf(q)
+ const nameIndex = resolveWorktreeDisplayName(worktree).toLowerCase().indexOf(q)
if (nameIndex !== -1) {
results.push(
makeResult(worktree.id, 'displayName', {
@@ -127,7 +130,7 @@ export function searchWorktrees(
continue
}
- const branch = branchName(worktree.branch)
+ const branch = resolveWorktreeBranchLabel(worktree)
const branchIndex = branch.toLowerCase().indexOf(q)
if (branchIndex !== -1) {
results.push(
diff --git a/src/renderer/src/store/slices/worktree-helpers.test.ts b/src/renderer/src/store/slices/worktree-helpers.test.ts
index fbd8875040b..2750be4445b 100644
--- a/src/renderer/src/store/slices/worktree-helpers.test.ts
+++ b/src/renderer/src/store/slices/worktree-helpers.test.ts
@@ -51,4 +51,21 @@ describe('applyWorktreeUpdates', () => {
expect(result['repo-b']?.[0]).toBe(samePathDifferentProject)
expect(result['repo-b']?.[0]?.displayName).toBe('Project B')
})
+
+ it('never lets a present-but-undefined value clobber existing worktree fields', () => {
+ const target = makeWorktree({
+ id: 'repo-a::/Users/alice/project',
+ repoId: 'repo-a',
+ displayName: 'Project A',
+ comment: 'notes'
+ })
+
+ const result = applyWorktreeUpdates({ 'repo-a': [target] }, target.id, {
+ displayName: undefined,
+ comment: 'edited'
+ })
+
+ expect(result['repo-a']?.[0]?.displayName).toBe('Project A')
+ expect(result['repo-a']?.[0]?.comment).toBe('edited')
+ })
})
diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts
index fee8e479709..bea4a374b69 100644
--- a/src/renderer/src/store/slices/worktree-helpers.ts
+++ b/src/renderer/src/store/slices/worktree-helpers.ts
@@ -326,11 +326,51 @@ export function findWorktreeById(
return undefined
}
+type RequiredKey = { [K in keyof T]-?: undefined extends T[K] ? never : K }[keyof T]
+
+// Why: a present-but-undefined key in a spread ERASES the field. That is the
+// intended wire signal for clearing optional metadata (pushTarget), but on a
+// field Worktree declares required it produced a live `displayName: undefined`
+// that crashed the worktree palette (crash a1f81ea1). Typed off Worktree so a
+// newly-required field is protected automatically.
+const ERASURE_PROTECTED_KEYS: Record, keyof WorktreeMeta>, true> = {
+ displayName: true,
+ comment: true,
+ linkedIssue: true,
+ linkedPR: true,
+ linkedLinearIssue: true,
+ isArchived: true,
+ isUnread: true,
+ isPinned: true,
+ sortOrder: true,
+ lastActivityAt: true
+}
+
+export function withoutErasedRequiredWorktreeFields(
+ updates: Partial
+): Partial {
+ const erased = Object.keys(ERASURE_PROTECTED_KEYS).filter(
+ (key) =>
+ updates[key as keyof WorktreeMeta] === undefined &&
+ Object.prototype.hasOwnProperty.call(updates, key)
+ )
+ if (erased.length === 0) {
+ return updates
+ }
+
+ const next = { ...updates }
+ for (const key of erased) {
+ delete next[key as keyof WorktreeMeta]
+ }
+ return next
+}
+
export function applyWorktreeUpdates(
worktreesByRepo: Record,
worktreeId: string,
- updates: Partial
+ rawUpdates: Partial
): Record {
+ const updates = withoutErasedRequiredWorktreeFields(rawUpdates)
const repoId = getRepoIdFromWorktreeId(worktreeId)
const worktrees = worktreesByRepo[repoId]
if (!worktrees) {
diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts
index 1d7ae9c5cd4..8b9f4e34e98 100644
--- a/src/renderer/src/store/slices/worktrees.ts
+++ b/src/renderer/src/store/slices/worktrees.ts
@@ -20,6 +20,7 @@ import type { RuntimeWorktreeListResult } from '../../../../shared/runtime-types
import {
findWorktreeById,
applyWorktreeUpdates,
+ withoutErasedRequiredWorktreeFields,
getRepoIdFromWorktreeId,
type DirectSshWorktreeFetchOptions,
type WorktreeFetchOptions,
@@ -706,8 +707,10 @@ function notifyRuntimeScopeForbiddenIfNeeded(error: unknown): boolean {
function applyDetectedWorktreeUpdates(
detectedWorktreesByRepo: AppState['detectedWorktreesByRepo'],
worktreeId: string,
- updates: Partial
+ rawUpdates: Partial
): AppState['detectedWorktreesByRepo'] {
+ // Why: mirrors applyWorktreeUpdates — detected rows feed the same palette.
+ const updates = withoutErasedRequiredWorktreeFields(rawUpdates)
let changed = false
const nextByRepo: AppState['detectedWorktreesByRepo'] = {}
From 238d3a1ea167396a1b38275846cea0dd5059b04a Mon Sep 17 00:00:00 2001
From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com>
Date: Wed, 29 Jul 2026 17:18:31 +0900
Subject: [PATCH 24/67] fix(terminal): verify clipboard writes so TUI "Copied"
never lies (#10827)
* fix(terminal): verify clipboard writes so TUI "Copied" never lies
Windows/Electron can return from clipboard.writeText without updating the
OS clipboard, so Claude Code / OpenCode OSC 52 copy and terminal selection
copy looked successful while paste stayed empty (#8977, same root as #5611).
Verify standard clipboard writes by reading back after write, surface OSC 52
host write failures with a toast, and route selection copy through a shared
helper that only clears the selection after a confirmed write.
* fix(terminal): harden clipboard write verification
* fix(terminal): contain clipboard failure notifications
* fix(terminal): isolate verified clipboard writes
* fix(terminal): address greptile clipboard verify nits
Drop the dead onWriteFailure pass-through from the coalesced OSC 52
handler so failure toasts stay owned by the microtask path. Cover
multi-line / CRLF identity in write+verify tests, and export the
verification-failed error constant for stable matching.
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
---
.../clipboard-dashboard-popout-access.test.ts | 31 ++++++++
.../window/clipboard-ipc-handlers.test.ts | 15 +++-
src/main/window/clipboard-ipc-handlers.ts | 6 ++
.../clipboard-text-write-verify.test.ts | 78 +++++++++++++++++++
.../window/clipboard-text-write-verify.ts | 12 +++
src/preload/api-types.ts | 1 +
src/preload/index.ts | 2 +
.../AgentTerminalPreview.test.tsx | 11 ++-
.../preview-terminal-key-handler.ts | 2 +-
.../terminal-pane/keyboard-handlers.ts | 9 ++-
....test.ts => osc52-clipboard-toast.test.ts} | 53 +++++++++++--
...cked-toast.ts => osc52-clipboard-toast.ts} | 23 ++++++
.../terminal-pane/osc52-clipboard.test.ts | 48 +++++++++++-
.../terminal-pane/osc52-clipboard.ts | 19 ++++-
.../terminal-agent-session-fork.test.ts | 4 +-
.../terminal-agent-session-fork.ts | 4 +-
.../terminal-selection-copy.test.ts | 55 +++++++++++++
.../terminal-pane/terminal-selection-copy.ts | 25 ++++++
.../use-terminal-pane-context-menu.ts | 17 ++--
.../use-terminal-pane-lifecycle.ts | 20 +++--
src/renderer/src/i18n/locales/en.json | 6 ++
src/renderer/src/i18n/locales/es.json | 6 ++
src/renderer/src/i18n/locales/ja.json | 6 ++
src/renderer/src/i18n/locales/ko.json | 6 ++
src/renderer/src/i18n/locales/zh.json | 6 ++
src/renderer/src/web/web-preload-api.test.ts | 11 ++-
src/renderer/src/web/web-preload-api.ts | 42 +++++-----
27 files changed, 457 insertions(+), 61 deletions(-)
create mode 100644 src/main/window/clipboard-text-write-verify.test.ts
create mode 100644 src/main/window/clipboard-text-write-verify.ts
rename src/renderer/src/components/terminal-pane/{osc52-clipboard-blocked-toast.test.ts => osc52-clipboard-toast.test.ts} (66%)
rename src/renderer/src/components/terminal-pane/{osc52-clipboard-blocked-toast.ts => osc52-clipboard-toast.ts} (69%)
create mode 100644 src/renderer/src/components/terminal-pane/terminal-selection-copy.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-selection-copy.ts
diff --git a/src/main/window/clipboard-dashboard-popout-access.test.ts b/src/main/window/clipboard-dashboard-popout-access.test.ts
index 0466853dbc1..2fd1ca680e2 100644
--- a/src/main/window/clipboard-dashboard-popout-access.test.ts
+++ b/src/main/window/clipboard-dashboard-popout-access.test.ts
@@ -77,6 +77,24 @@ describe('dashboard popout clipboard access', () => {
expect(clipboardWriteText).toHaveBeenCalledWith('terminal selection')
})
+ it('keeps legacy writes non-rejecting while verified terminal writes report stale text', async () => {
+ await expect(
+ handlers.get('clipboard:writeText')?.(popoutEvent, 'legacy copy')
+ ).resolves.toBeUndefined()
+ await expect(
+ handlers.get('clipboard:writeTerminalText')?.(popoutEvent, 'verified terminal copy')
+ ).rejects.toThrow('Clipboard write verification failed')
+
+ clipboardWriteText.mockImplementation((text: string) => clipboardReadText.mockReturnValue(text))
+ await expect(
+ handlers.get('clipboard:writeTerminalText')?.(popoutEvent, 'confirmed terminal copy')
+ ).resolves.toBeUndefined()
+
+ expect(clipboardWriteText).toHaveBeenCalledWith('legacy copy')
+ expect(clipboardWriteText).toHaveBeenCalledWith('verified terminal copy')
+ expect(clipboardWriteText).toHaveBeenCalledWith('confirmed terminal copy')
+ })
+
it('does not extend popout authority to selection, image, file, or remote clipboard APIs', async () => {
await expect(handlers.get('clipboard:readSelectionText')?.(popoutEvent)).rejects.toThrow(
'Unauthorized clipboard IPC sender'
@@ -111,8 +129,21 @@ describe('dashboard popout clipboard access', () => {
await expect(handlers.get('clipboard:writeText')?.(popoutEvent, 'secret')).rejects.toThrow(
'Unauthorized clipboard IPC sender'
)
+ await expect(
+ handlers.get('clipboard:writeTerminalText')?.(popoutEvent, 'secret')
+ ).rejects.toThrow('Unauthorized clipboard IPC sender')
expect(clipboardReadText).not.toHaveBeenCalled()
expect(clipboardWriteText).not.toHaveBeenCalled()
})
+
+ it('applies the text size gate to verified terminal writes', async () => {
+ await expect(
+ handlers.get('clipboard:writeTerminalText')?.(
+ popoutEvent,
+ 'copied-secret-token-value'.repeat(900_000)
+ )
+ ).rejects.toThrow('Clipboard text is too large to copy safely.')
+ expect(clipboardWriteText).not.toHaveBeenCalled()
+ })
})
diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts
index b1465beb82d..406949ac69e 100644
--- a/src/main/window/clipboard-ipc-handlers.test.ts
+++ b/src/main/window/clipboard-ipc-handlers.test.ts
@@ -224,9 +224,17 @@ describe('registerClipboardHandlers', () => {
})
it('registers normal and selection text clipboard IPC handlers', async () => {
- clipboardReadTextMock.mockImplementation((clipboardType?: string) =>
- clipboardType === 'selection' ? 'selection text' : 'standard text'
+ const values = { standard: 'standard text', selection: 'selection text' }
+ clipboardReadTextMock.mockImplementation((type?: string) =>
+ type === 'selection' ? values.selection : values.standard
)
+ clipboardWriteTextMock.mockImplementation((text: string, type?: string) => {
+ if (type === 'selection') {
+ values.selection = text
+ } else {
+ values.standard = text
+ }
+ })
registerClipboardHandlers({} as never)
@@ -489,6 +497,9 @@ describe('registerClipboardHandlers', () => {
it('yields before writing large text clipboard IPC payloads', async () => {
vi.useFakeTimers()
const text = 'é'.repeat(300_000)
+ clipboardWriteTextMock.mockImplementation((value: string) => {
+ clipboardReadTextMock.mockReturnValue(value)
+ })
registerClipboardHandlers({} as never)
diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts
index f519da7ed4b..275c0e26240 100644
--- a/src/main/window/clipboard-ipc-handlers.ts
+++ b/src/main/window/clipboard-ipc-handlers.ts
@@ -35,6 +35,7 @@ import {
} from './clipboard-remote-file-copy'
import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload'
import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-file'
+import { writeClipboardTextAndVerify } from './clipboard-text-write-verify'
import { isDashboardPopoutRenderer } from './dashboard-popout-window'
let trustedClipboardRendererWebContentsId: number | null = null
@@ -77,6 +78,7 @@ export function registerClipboardHandlers(store: Store): void {
ipcMain.removeHandler('clipboard:readText')
ipcMain.removeHandler('clipboard:readSelectionText')
ipcMain.removeHandler('clipboard:writeText')
+ ipcMain.removeHandler('clipboard:writeTerminalText')
ipcMain.removeHandler('clipboard:writeSelectionText')
ipcMain.removeHandler('clipboard:writeImage')
ipcMain.removeHandler('clipboard:writeFile')
@@ -159,6 +161,10 @@ export function registerClipboardHandlers(store: Store): void {
assertTrustedClipboardTextSender(event)
return clipboard.writeText(await assertClipboardTextWriteWithinLimitWithYield(text))
})
+ ipcMain.handle('clipboard:writeTerminalText', async (event, text: string) => {
+ assertTrustedClipboardTextSender(event)
+ return writeClipboardTextAndVerify(await assertClipboardTextWriteWithinLimitWithYield(text))
+ })
ipcMain.handle('clipboard:writeSelectionText', async (event, text: string) => {
assertTrustedClipboardSender(event)
return clipboard.writeText(
diff --git a/src/main/window/clipboard-text-write-verify.test.ts b/src/main/window/clipboard-text-write-verify.test.ts
new file mode 100644
index 00000000000..7b1e1f451c4
--- /dev/null
+++ b/src/main/window/clipboard-text-write-verify.test.ts
@@ -0,0 +1,78 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { clipboardReadTextMock, clipboardWriteTextMock } = vi.hoisted(() => ({
+ clipboardReadTextMock: vi.fn(),
+ clipboardWriteTextMock: vi.fn()
+}))
+
+vi.mock('electron', () => ({
+ clipboard: {
+ readText: clipboardReadTextMock,
+ writeText: clipboardWriteTextMock
+ }
+}))
+
+import {
+ CLIPBOARD_WRITE_VERIFICATION_FAILED_ERROR,
+ writeClipboardTextAndVerify
+} from './clipboard-text-write-verify'
+
+describe('writeClipboardTextAndVerify', () => {
+ beforeEach(() => {
+ clipboardReadTextMock.mockReset()
+ clipboardWriteTextMock.mockReset()
+ })
+
+ it('writes then accepts a matching standard clipboard read-back', () => {
+ clipboardWriteTextMock.mockImplementation((text: string) => {
+ clipboardReadTextMock.mockReturnValue(text)
+ })
+
+ expect(() => writeClipboardTextAndVerify('tui answer')).not.toThrow()
+ expect(clipboardWriteTextMock).toHaveBeenCalledWith('tui answer')
+ expect(clipboardReadTextMock).toHaveBeenCalledWith()
+ })
+
+ it('accepts multi-line TUI content when read-back is identity-preserving', () => {
+ // Primary real-world path: code/agent output almost always contains newlines.
+ const multiLine = 'line1\nline2\n indented\n'
+ clipboardWriteTextMock.mockImplementation((text: string) => {
+ clipboardReadTextMock.mockReturnValue(text)
+ })
+
+ expect(() => writeClipboardTextAndVerify(multiLine)).not.toThrow()
+ expect(clipboardWriteTextMock).toHaveBeenCalledWith(multiLine)
+ expect(clipboardReadTextMock).toHaveBeenCalledWith()
+ })
+
+ it('accepts CRLF multi-line content only when read-back matches exactly', () => {
+ // Guard against platforms that normalize line endings between write and read.
+ const crlf = 'line1\r\nline2\r\n'
+ clipboardWriteTextMock.mockImplementation((text: string) => {
+ clipboardReadTextMock.mockReturnValue(text)
+ })
+
+ expect(() => writeClipboardTextAndVerify(crlf)).not.toThrow()
+ expect(clipboardWriteTextMock).toHaveBeenCalledWith(crlf)
+ })
+
+ it('rejects when multi-line read-back differs only by line endings', () => {
+ clipboardWriteTextMock.mockImplementation(() => {
+ // e.g. write LF, OS returns CRLF — strict verify must fail rather than lie.
+ clipboardReadTextMock.mockReturnValue('line1\r\nline2')
+ })
+
+ expect(() => writeClipboardTextAndVerify('line1\nline2')).toThrow(
+ CLIPBOARD_WRITE_VERIFICATION_FAILED_ERROR
+ )
+ })
+
+ it('rejects standard text writes when the clipboard read-back does not match', () => {
+ clipboardReadTextMock.mockReturnValue('old clipboard')
+
+ expect(() => writeClipboardTextAndVerify('tui answer')).toThrow(
+ CLIPBOARD_WRITE_VERIFICATION_FAILED_ERROR
+ )
+ expect(clipboardWriteTextMock).toHaveBeenCalledWith('tui answer')
+ })
+})
diff --git a/src/main/window/clipboard-text-write-verify.ts b/src/main/window/clipboard-text-write-verify.ts
new file mode 100644
index 00000000000..32f7d6d5903
--- /dev/null
+++ b/src/main/window/clipboard-text-write-verify.ts
@@ -0,0 +1,12 @@
+import { clipboard } from 'electron'
+
+export const CLIPBOARD_WRITE_VERIFICATION_FAILED_ERROR = 'Clipboard write verification failed'
+
+// Electron can silently leave the Windows clipboard unchanged under contention.
+export function writeClipboardTextAndVerify(text: string): void {
+ clipboard.writeText(text)
+ // Strict identity: multi-line TUI content must round-trip byte-for-byte.
+ if (clipboard.readText() !== text) {
+ throw new Error(CLIPBOARD_WRITE_VERIFICATION_FAILED_ERROR)
+ }
+}
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index 180e48d2d65..9431db3396a 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -3223,6 +3223,7 @@ export type PreloadApi = {
runtimeEnvironmentId?: string | null
}) => Promise
writeClipboardText: (text: string) => Promise
+ writeTerminalClipboardText: (text: string) => Promise
writeSelectionClipboardText: (text: string) => Promise
writeClipboardImage: (dataUrl: string) => Promise
performNativePaste: (options?: { mode?: 'paste' | 'paste-and-match-style' }) => void
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 22f05f2dc38..8df862d0c4c 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -3947,6 +3947,8 @@ const api = {
}): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args),
writeClipboardText: (text: string): Promise =>
ipcRenderer.invoke('clipboard:writeText', text),
+ writeTerminalClipboardText: (text: string): Promise =>
+ ipcRenderer.invoke('clipboard:writeTerminalText', text),
writeSelectionClipboardText: (text: string): Promise =>
ipcRenderer.invoke('clipboard:writeSelectionText', text),
writeClipboardImage: (dataUrl: string): Promise =>
diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx
index 01ac36f41ed..76cfa011611 100644
--- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx
+++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx
@@ -153,6 +153,7 @@ describe('AgentTerminalPreview', () => {
const connect = vi.fn()
const readClipboardText = vi.fn(async () => 'clip-text')
const writeClipboardText = vi.fn(async () => {})
+ const writeTerminalClipboardText = vi.fn(async () => {})
let emitData: ((payload: unknown) => void) | null
let emitAppMenuPaste: (() => void) | null
@@ -188,6 +189,7 @@ describe('AgentTerminalPreview', () => {
ui: {
readClipboardText,
writeClipboardText,
+ writeTerminalClipboardText,
onAppMenuPaste: (listener: () => void) => {
emitAppMenuPaste = listener
return vi.fn()
@@ -248,6 +250,7 @@ describe('AgentTerminalPreview', () => {
)
expect(handled).toBe(false)
expect(writeClipboardText).not.toHaveBeenCalled()
+ expect(writeTerminalClipboardText).not.toHaveBeenCalled()
// Unclaimed events still reach the chord handling.
imeHarness.claimResult = false
@@ -255,7 +258,8 @@ describe('AgentTerminalPreview', () => {
new KeyboardEvent('keydown', { key: 'C', code: 'KeyC', metaKey: true, shiftKey: true })
)
expect(copied).toBe(false)
- expect(writeClipboardText).toHaveBeenCalledWith('selected text')
+ expect(writeTerminalClipboardText).toHaveBeenCalledWith('selected text')
+ expect(writeClipboardText).not.toHaveBeenCalled()
expect(imeHarness.inputSourceTrackerRequests).toBe(1)
})
@@ -316,7 +320,8 @@ describe('AgentTerminalPreview', () => {
expect(handled).toBe(false)
expect(keyupHandled).toBe(false)
expect(keydown.defaultPrevented).toBe(true)
- expect(writeClipboardText).toHaveBeenCalledWith('selected text')
+ expect(writeTerminalClipboardText).toHaveBeenCalledWith('selected text')
+ expect(writeClipboardText).not.toHaveBeenCalled()
})
it('keeps an empty copy chord from leaking terminal input', async () => {
@@ -329,7 +334,7 @@ describe('AgentTerminalPreview', () => {
new KeyboardEvent('keydown', { key: 'C', code: 'KeyC', ctrlKey: true, shiftKey: true })
)
expect(handled).toBe(false)
- expect(writeClipboardText).not.toHaveBeenCalled()
+ expect(writeTerminalClipboardText).not.toHaveBeenCalled()
})
it('pastes clipboard text on the app-menu paste signal while the preview owns focus', async () => {
diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts
index 4eb7157e43d..c39cf94fc3d 100644
--- a/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts
+++ b/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts
@@ -108,7 +108,7 @@ export function installPreviewTerminalKeyHandler(args: {
consumedClipboardKeys.add(keyIdentity)
const selection = terminal.getSelection()
if (firstKeydown && selection) {
- void window.api.ui.writeClipboardText(selection).catch(() => undefined)
+ void window.api.ui.writeTerminalClipboardText(selection).catch(() => undefined)
}
return consumeEvent(event)
}
diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts
index 357ac5db0d4..ee6079167d2 100644
--- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts
+++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts
@@ -29,6 +29,7 @@ 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 { copyTerminalSelection } from './terminal-selection-copy'
import { isLocalWindowsConptyPaneForCtrlArrow } from './terminal-ctrl-arrow-conpty'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { resolveWindowsShiftEnterEncodingForPane } from './terminal-windows-shift-enter'
@@ -447,13 +448,15 @@ export function useTerminalKeyboardShortcuts({
if (!pane) {
return
}
- const selection = pane.terminal.getSelection()
- if (!selection) {
+ if (!pane.terminal.getSelection()) {
return
}
e.preventDefault()
e.stopImmediatePropagation()
- void window.api.ui.writeClipboardText(selection).catch(() => {
+ void copyTerminalSelection({
+ terminal: pane.terminal,
+ writeClipboardText: window.api.ui.writeTerminalClipboardText
+ }).catch(() => {
/* ignore clipboard write failures */
})
return
diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard-toast.test.ts
similarity index 66%
rename from src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts
rename to src/renderer/src/components/terminal-pane/osc52-clipboard-toast.test.ts
index 4fa8229bcf9..ccaec75eb69 100644
--- a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts
+++ b/src/renderer/src/components/terminal-pane/osc52-clipboard-toast.test.ts
@@ -5,10 +5,11 @@ import ja from '@/i18n/locales/ja.json'
import ko from '@/i18n/locales/ko.json'
import zh from '@/i18n/locales/zh.json'
import { OSC52_CLIPBOARD_SETTING_ID } from './osc52-clipboard-setting-anchor'
-import type * as Osc52ClipboardBlockedToastModule from './osc52-clipboard-blocked-toast'
+import type * as Osc52ClipboardToastModule from './osc52-clipboard-toast'
-const { toastInfoMock, storeMock } = vi.hoisted(() => ({
+const { toastInfoMock, toastErrorMock, storeMock } = vi.hoisted(() => ({
toastInfoMock: vi.fn(),
+ toastErrorMock: vi.fn(),
storeMock: {
setSettingsSearchQuery: vi.fn(),
openSettingsTarget: vi.fn(),
@@ -18,7 +19,8 @@ const { toastInfoMock, storeMock } = vi.hoisted(() => ({
vi.mock('sonner', () => ({
toast: {
- info: toastInfoMock
+ info: toastInfoMock,
+ error: toastErrorMock
}
}))
@@ -28,14 +30,15 @@ vi.mock('@/store', () => ({
}
}))
-async function importToastModule(): Promise {
- return import('./osc52-clipboard-blocked-toast')
+async function importToastModule(): Promise {
+ return import('./osc52-clipboard-toast')
}
describe('showOsc52ClipboardBlockedToast', () => {
beforeEach(() => {
vi.resetModules()
toastInfoMock.mockReset()
+ toastErrorMock.mockReset()
storeMock.setSettingsSearchQuery.mockReset()
storeMock.openSettingsTarget.mockReset()
storeMock.openSettingsPage.mockReset()
@@ -102,3 +105,43 @@ describe('showOsc52ClipboardBlockedToast', () => {
}
})
})
+
+describe('showOsc52ClipboardFailedToast', () => {
+ beforeEach(() => {
+ vi.resetModules()
+ toastErrorMock.mockReset()
+ })
+
+ it('reports that the host clipboard copy could not be confirmed', async () => {
+ const { showOsc52ClipboardFailedToast } = await importToastModule()
+
+ showOsc52ClipboardFailedToast()
+
+ expect(toastErrorMock).toHaveBeenCalledWith('Terminal clipboard copy could not be confirmed', {
+ description:
+ 'The terminal app requested a copy, but Orca could not confirm that it reached the system clipboard.',
+ duration: 12_000
+ })
+ })
+
+ it('only shows once per renderer session', async () => {
+ const { showOsc52ClipboardFailedToast } = await importToastModule()
+
+ showOsc52ClipboardFailedToast()
+ showOsc52ClipboardFailedToast()
+
+ expect(toastErrorMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps the session notice unspent when the toast throws', async () => {
+ const { showOsc52ClipboardFailedToast } = await importToastModule()
+ toastErrorMock.mockImplementationOnce(() => {
+ throw new Error('toast unavailable')
+ })
+
+ expect(() => showOsc52ClipboardFailedToast()).toThrow('toast unavailable')
+ showOsc52ClipboardFailedToast()
+
+ expect(toastErrorMock).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard-toast.ts
similarity index 69%
rename from src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts
rename to src/renderer/src/components/terminal-pane/osc52-clipboard-toast.ts
index fad6a58a6fd..2474836edee 100644
--- a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts
+++ b/src/renderer/src/components/terminal-pane/osc52-clipboard-toast.ts
@@ -4,6 +4,7 @@ import { OSC52_CLIPBOARD_SETTING_ID } from './osc52-clipboard-setting-anchor'
import { translate } from '@/i18n/i18n'
let hasShownOsc52ClipboardBlockedToast = false
+let hasShownOsc52ClipboardFailedToast = false
export function showOsc52ClipboardBlockedToast(): void {
if (hasShownOsc52ClipboardBlockedToast) {
@@ -45,3 +46,25 @@ export function showOsc52ClipboardBlockedToast(): void {
// without ever showing it, leaving the opted-out user with silent failures.
hasShownOsc52ClipboardBlockedToast = true
}
+
+export function showOsc52ClipboardFailedToast(): void {
+ if (hasShownOsc52ClipboardFailedToast) {
+ return
+ }
+
+ toast.error(
+ translate(
+ 'auto.components.terminal.pane.osc52.clipboard.failed.toast.62a0af2cb4',
+ 'Terminal clipboard copy could not be confirmed'
+ ),
+ {
+ description: translate(
+ 'auto.components.terminal.pane.osc52.clipboard.failed.toast.fdd3e7e977',
+ 'The terminal app requested a copy, but Orca could not confirm that it reached the system clipboard.'
+ ),
+ duration: 12_000
+ }
+ )
+ // Why latch after: same as blocked — don't burn the session notice on a throw.
+ hasShownOsc52ClipboardFailedToast = true
+}
diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts
index 8c1dada1643..a822516e8c6 100644
--- a/src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts
+++ b/src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts
@@ -168,6 +168,20 @@ describe('handleOsc52ClipboardRequest', () => {
expect(onBlockedWrite).not.toHaveBeenCalled()
})
+
+ it('surfaces host clipboard write failures for OSC 52 requests', async () => {
+ const onWriteFailure = vi.fn()
+
+ handleOsc52ClipboardRequest(`c;${b64('from tui')}`, {
+ allowClipboardWrite: true,
+ writeClipboardText: vi
+ .fn<(text: string) => Promise>()
+ .mockRejectedValue(new Error('clipboard unchanged')),
+ onWriteFailure
+ })
+
+ await vi.waitFor(() => expect(onWriteFailure).toHaveBeenCalledTimes(1))
+ })
})
describe('createOsc52OscHandler', () => {
@@ -176,6 +190,7 @@ describe('createOsc52OscHandler', () => {
settingEnabled?: boolean | null
replaying?: boolean
writeClipboardText?: ReturnType Promise>>
+ showWriteFailedToast?: ReturnType void>>
} = {}
) {
const settingEnabled = 'settingEnabled' in overrides ? overrides.settingEnabled : true
@@ -183,13 +198,15 @@ describe('createOsc52OscHandler', () => {
overrides.writeClipboardText ??
vi.fn<(text: string) => Promise>().mockResolvedValue(undefined)
const showBlockedWriteToast = vi.fn()
+ const showWriteFailedToast = overrides.showWriteFailedToast ?? vi.fn()
const handler = createOsc52OscHandler({
getSettingEnabled: () => settingEnabled,
getReplaying: () => overrides.replaying ?? false,
writeClipboardText,
- showBlockedWriteToast
+ showBlockedWriteToast,
+ showWriteFailedToast
})
- return { handler, writeClipboardText, showBlockedWriteToast }
+ return { handler, writeClipboardText, showBlockedWriteToast, showWriteFailedToast }
}
it('writes through to the clipboard for a live pane', async () => {
@@ -267,7 +284,7 @@ describe('createOsc52OscHandler', () => {
expect(writeClipboardText).toHaveBeenCalledWith('copy me')
})
- it('swallows a rejected clipboard write rather than leaking an unhandled rejection', async () => {
+ it('surfaces a rejected clipboard write without leaking an unhandled rejection', async () => {
// Why the listener: an unhandled rejection here does not fail this suite on its own,
// so without it the `.catch` on the coalesced write is deletable with nothing going red.
const unhandled: unknown[] = []
@@ -276,6 +293,9 @@ describe('createOsc52OscHandler', () => {
}
process.on('unhandledRejection', record)
const written: string[] = []
+ const showWriteFailedToast = vi.fn(() => {
+ throw new Error('toast unavailable')
+ })
try {
// Why not vi.fn here: the spy tracks settled results, which marks the rejection
// handled and hides exactly the leak this test exists to catch.
@@ -286,7 +306,8 @@ describe('createOsc52OscHandler', () => {
written.push(text)
return Promise.reject(new Error('denied by OS'))
},
- showBlockedWriteToast: vi.fn()
+ showBlockedWriteToast: vi.fn(),
+ showWriteFailedToast
})
expect(handler(`c;${b64('copy me')}`)).toBe(true)
@@ -297,6 +318,25 @@ describe('createOsc52OscHandler', () => {
expect(written).toEqual(['copy me'])
expect(unhandled).toEqual([])
+ expect(showWriteFailedToast).toHaveBeenCalledTimes(1)
+ })
+
+ it('surfaces a synchronous clipboard bridge throw as a failed write toast', async () => {
+ const showWriteFailedToast = vi.fn(() => {
+ throw new Error('toast unavailable')
+ })
+ const { handler } = setup({
+ writeClipboardText: vi.fn<(text: string) => Promise>(() => {
+ throw new Error('clipboard unavailable')
+ }),
+ showWriteFailedToast
+ })
+
+ expect(handler(`c;${b64('copy me')}`)).toBe(true)
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(showWriteFailedToast).toHaveBeenCalledTimes(1)
})
it('keeps coalescing after a failed write instead of wedging the pane', async () => {
diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard.ts
index a2b074f2e84..4bf6d782a3f 100644
--- a/src/renderer/src/components/terminal-pane/osc52-clipboard.ts
+++ b/src/renderer/src/components/terminal-pane/osc52-clipboard.ts
@@ -40,10 +40,19 @@ export type Osc52ClipboardRequestOptions = {
allowClipboardWrite: boolean
writeClipboardText: (text: string) => Promise
onBlockedWrite?: () => void
+ onWriteFailure?: () => void
}
const MAX_OSC52_BASE64_CHARS = 128 * 1024
+function reportOsc52ClipboardWriteFailure(notify?: () => void): void {
+ try {
+ notify?.()
+ } catch {
+ // A failed notifier must not escape an already-handled clipboard failure.
+ }
+}
+
/** Resolves whether an incoming OSC 52 write may touch the clipboard, and whether a
* refusal is worth telling the user about. */
export function resolveOsc52ClipboardGate(input: {
@@ -80,6 +89,7 @@ export function createOsc52OscHandler(deps: {
getReplaying: () => boolean
writeClipboardText: (text: string) => Promise
showBlockedWriteToast: () => void
+ showWriteFailedToast?: () => void
}): (data: string) => boolean {
// Why coalesce: each sequence is only ~15 bytes, so one hostile chunk can fire a
// million parser callbacks — each a main-process clipboard write. Only the last of
@@ -99,16 +109,19 @@ export function createOsc52OscHandler(deps: {
// Why try/catch and not just .catch(): the write moved out of the guarded
// parser handler into a microtask, where a sync throw (or a preload that
// never installed writeClipboardText) would surface as an uncaught error.
+ // Report the otherwise invisible host failure.
try {
void deps.writeClipboardText(next)?.catch(() => {
- /* ignore clipboard write failures */
+ reportOsc52ClipboardWriteFailure(deps.showWriteFailedToast)
})
} catch {
- /* ignore clipboard write failures */
+ reportOsc52ClipboardWriteFailure(deps.showWriteFailedToast)
}
}
})
}
+ // Always resolve here: failure toast is raised in the microtask above.
+ // Passing onWriteFailure to handleOsc52ClipboardRequest would be dead.
return Promise.resolve()
}
@@ -140,7 +153,7 @@ export function handleOsc52ClipboardRequest(
}
void options.writeClipboardText(parsed.text).catch(() => {
- /* ignore clipboard write failures */
+ reportOsc52ClipboardWriteFailure(options.onWriteFailure)
})
return true
}
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..73bab964bbe 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
@@ -105,7 +105,7 @@ describe('forkAgentSessionFromPane', () => {
vi.stubGlobal('window', {
api: {
ui: {
- writeClipboardText: mockWriteClipboardText
+ writeTerminalClipboardText: mockWriteClipboardText
},
agentTrust: {
markTrusted: mockMarkTrusted
@@ -521,7 +521,7 @@ describe('copyAgentSessionContextFromPane', () => {
vi.clearAllMocks()
mockWriteClipboardText.mockResolvedValue(undefined)
vi.stubGlobal('window', {
- api: { ui: { writeClipboardText: mockWriteClipboardText } }
+ api: { ui: { writeTerminalClipboardText: mockWriteClipboardText } }
})
})
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..1b7c36a0a1f 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
@@ -65,7 +65,7 @@ function getUsableForkBase(
async function copyForkContext(prompt: string, pane: ManagedPane): Promise {
try {
- await window.api.ui.writeClipboardText(prompt)
+ await window.api.ui.writeTerminalClipboardText(prompt)
toast.message(
translate(
'auto.components.terminal.pane.terminal.agent.session.fork.c00421d320',
@@ -189,7 +189,7 @@ export async function copyAgentSessionContextFromPane(pane: ManagedPane): Promis
return false
}
try {
- await window.api.ui.writeClipboardText(transcript)
+ await window.api.ui.writeTerminalClipboardText(transcript)
toast.message(
translate(
'auto.components.terminal.pane.terminal.agent.session.fork.373a3103e7',
diff --git a/src/renderer/src/components/terminal-pane/terminal-selection-copy.test.ts b/src/renderer/src/components/terminal-pane/terminal-selection-copy.test.ts
new file mode 100644
index 00000000000..91c561c6e76
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-selection-copy.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it, vi } from 'vitest'
+import { copyTerminalSelection } from './terminal-selection-copy'
+
+function makeTerminal(selection: string) {
+ return {
+ getSelection: vi.fn(() => selection),
+ clearSelection: vi.fn()
+ }
+}
+
+describe('copyTerminalSelection', () => {
+ it('writes selected terminal text to the clipboard', async () => {
+ const terminal = makeTerminal('copilot answer')
+ const writeClipboardText = vi.fn<(text: string) => Promise>().mockResolvedValue()
+
+ await expect(copyTerminalSelection({ terminal, writeClipboardText })).resolves.toBe(true)
+
+ expect(writeClipboardText).toHaveBeenCalledWith('copilot answer')
+ expect(terminal.clearSelection).not.toHaveBeenCalled()
+ })
+
+ it('does not claim success for an empty xterm selection', async () => {
+ const terminal = makeTerminal('')
+ const writeClipboardText = vi.fn<(text: string) => Promise>().mockResolvedValue()
+
+ await expect(copyTerminalSelection({ terminal, writeClipboardText })).resolves.toBe(false)
+
+ expect(writeClipboardText).not.toHaveBeenCalled()
+ expect(terminal.clearSelection).not.toHaveBeenCalled()
+ })
+
+ it('clears xterm selection only after the clipboard write succeeds', async () => {
+ const terminal = makeTerminal('copilot answer')
+ const writeClipboardText = vi
+ .fn<(text: string) => Promise