Merge remote-tracking branch 'origin/main' into brennanb2025/issue-5918-automation-setup-hook

# Conflicts:
#	src/main/runtime/orca-runtime.test.ts
#	src/main/runtime/orca-runtime.ts
#	src/renderer/src/lib/worktree-activation.ts
This commit is contained in:
brennanb2025
2026-06-28 14:47:26 -07:00
134 changed files with 13818 additions and 2228 deletions
+2 -2
View File
@@ -42,14 +42,14 @@ jobs:
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
# for Amazon Bedrock (https://docs.pullfrog.com/bedrock)
# AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# AWS_REGION: us-east-1
# BEDROCK_MODEL_ID: <bedrock-model-id>
# for Google Vertex AI (https://docs.pullfrog.com/vertex)
# VERTEX_SERVICE_ACCOUNT_JSON: ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }}
# GOOGLE_CLOUD_PROJECT: my-project
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.104-rc.3",
"version": "1.4.104-rc.6",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
+7 -2
View File
@@ -1,10 +1,15 @@
export type GitRuntimeOptions = {
wslDistro?: string
signal?: AbortSignal
}
export function gitOptionsForWorktree(
cwd: string,
options: GitRuntimeOptions = {}
): { cwd: string; wslDistro?: string } {
return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd }
): { cwd: string; wslDistro?: string; signal?: AbortSignal } {
return {
cwd,
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
...(options.signal ? { signal: options.signal } : {})
}
}
+14
View File
@@ -836,6 +836,7 @@ type GitStreamOptions = {
cwd: string
env?: NodeJS.ProcessEnv
wslDistro?: string
signal?: AbortSignal
/** Byte backstop; defaults to DEFAULT_GIT_MAX_BUFFER. */
maxBuffer?: number
/**
@@ -863,6 +864,10 @@ export async function gitStreamStdout(
const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER
return withGitSpan({ args, cwd: options.cwd }, async () => {
return new Promise<GitStreamResult>((resolve, reject) => {
if (options.signal?.aborted) {
reject(createAbortError())
return
}
const child = gitSpawn(args, {
cwd: options.cwd,
env: nonInteractiveGitEnv(options.env),
@@ -887,6 +892,7 @@ export async function gitStreamStdout(
child.stderr?.off('data', onStderrData)
child.off('error', onError)
child.off('close', onClose)
options.signal?.removeEventListener('abort', onAbort)
// Flush any bytes the decoders were holding for an incomplete sequence.
stdoutDecoder.end()
stderrDecoder.end()
@@ -953,11 +959,19 @@ export async function gitStreamStdout(
}
finish(new Error(`git exited with ${code}: ${stderr}`))
}
function onAbort(): void {
killSpawnedCommandTree(child)
finish(createAbortError())
}
child.stdout?.on('data', onStdoutData)
child.stderr?.on('data', onStderrData)
child.on('error', onError)
child.on('close', onClose)
options.signal?.addEventListener('abort', onAbort, { once: true })
if (options.signal?.aborted) {
onAbort()
}
})
})
}
+4
View File
@@ -109,6 +109,9 @@ export async function getStatus(
options: GetStatusOptions = {}
): Promise<GitStatusResult> {
gitDiffReadDedupe.clear()
if (options.signal) {
return runGetStatus(worktreePath, options)
}
// Why: dedupe only concurrent identical reads; after settle, callers must
// execute a fresh status read rather than observing a cached result.
const cacheKey = getStatusReadKey(worktreePath, options)
@@ -190,6 +193,7 @@ async function runGetStatus(
// Why: status polling is read-like; avoid refreshing the index and racing
// terminal Git commands on `.git/worktrees/*/index.lock`.
env: gitOptionalLocksDisabledEnv(),
signal: options.signal,
onStdout: (chunk) => parser.update(chunk, limit)
})
if (!stoppedEarly) {
@@ -0,0 +1,192 @@
import { execFileSync } from 'child_process'
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import * as path from 'path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as GitRunner from './runner'
// Why: spy on the git runner so we can count rev-parse invocations while still
// running real git, proving the main-entry-equals-repoPath early return skips
// the extra subprocess for an ordinary repo scanned at its own root.
const revParseTopLevelCalls = { count: 0 }
vi.mock('./runner', async () => {
const actual = await vi.importActual<typeof GitRunner>('./runner')
return {
...actual,
gitExecFileAsync: (args: string[], options?: unknown) => {
if (args[0] === 'rev-parse' && args.includes('--show-toplevel')) {
revParseTopLevelCalls.count += 1
}
return (actual.gitExecFileAsync as (a: string[], o?: unknown) => Promise<unknown>)(
args,
options
)
}
}
})
// Imported after vi.mock so worktree.ts binds to the spied runner.
const { listWorktrees } = await import('./worktree')
const tempRoots: string[] = []
function git(cwd: string, args: string[]): string {
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
}
async function createCommittedRepo(root: string, name: string): Promise<string> {
const repoPath = path.join(root, name)
execFileSync('git', ['init', '--quiet', repoPath])
git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main'])
git(repoPath, ['config', 'user.email', 'test@example.com'])
git(repoPath, ['config', 'user.name', 'Test User'])
await writeFile(path.join(repoPath, 'README.md'), `${name}\n`)
git(repoPath, ['add', 'README.md'])
git(repoPath, ['commit', '--quiet', '-m', 'initial'])
return realpath(repoPath)
}
async function createSeparateGitDirRepo(): Promise<{
gitDirPath: string
worktreePath: string
}> {
const root = await mkdtemp(path.join(tmpdir(), 'orca-separate-git-dir-'))
tempRoots.push(root)
const sourcePath = await createCommittedRepo(root, 'source')
const requestedWorktreePath = path.join(root, 'worktree')
const requestedGitDirPath = path.join(root, 'git-store', 'project.git')
await mkdir(path.dirname(requestedGitDirPath), { recursive: true })
execFileSync('git', [
'clone',
'--quiet',
`--separate-git-dir=${requestedGitDirPath}`,
sourcePath,
requestedWorktreePath
])
return {
gitDirPath: await realpath(requestedGitDirPath),
worktreePath: await realpath(requestedWorktreePath)
}
}
async function createNormalRepo(): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), 'orca-normal-worktree-'))
tempRoots.push(root)
return createCommittedRepo(root, 'repo')
}
async function createBareRepo(): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), 'orca-bare-worktree-'))
tempRoots.push(root)
const repoPath = path.join(root, 'repo.git')
execFileSync('git', ['init', '--bare', '--quiet', repoPath])
return realpath(repoPath)
}
afterEach(async () => {
revParseTopLevelCalls.count = 0
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('git worktree separate git dir paths', () => {
it.skipIf(process.platform === 'win32')(
'normalizes the main worktree path for a separate-git-dir repo',
async () => {
const { gitDirPath, worktreePath } = await createSeparateGitDirRepo()
const worktrees = await listWorktrees(worktreePath)
const mainWorktree = worktrees.find((worktree) => worktree.isMainWorktree)
expect(mainWorktree).toMatchObject({
path: worktreePath,
isMainWorktree: true
})
expect(mainWorktree?.path).not.toBe(gitDirPath)
}
)
it.skipIf(process.platform === 'win32')(
'fires exactly one rev-parse for a separate-git-dir repo',
async () => {
const { worktreePath } = await createSeparateGitDirRepo()
await listWorktrees(worktreePath)
expect(revParseTopLevelCalls.count).toBe(1)
}
)
it.skipIf(process.platform === 'win32')('leaves a normal repo main path unchanged', async () => {
const repoPath = await createNormalRepo()
const worktrees = await listWorktrees(repoPath)
const mainWorktree = worktrees.find((worktree) => worktree.isMainWorktree)
expect(mainWorktree).toMatchObject({
path: repoPath,
isMainWorktree: true
})
})
it.skipIf(process.platform === 'win32')(
'leaves an ordinary repo reached via a symlinked path unchanged',
async () => {
// A symlink alias of the checkout root defeats the path-string gate
// (git reports the realpath toplevel), so the git-common-dir gate must
// still skip the rewrite since the main entry is a real working root.
const repoPath = await createNormalRepo()
const linkRoot = await mkdtemp(path.join(tmpdir(), 'orca-symlink-worktree-'))
tempRoots.push(linkRoot)
const linkedRepoPath = path.join(linkRoot, 'linked-repo')
await symlink(repoPath, linkedRepoPath)
const worktrees = await listWorktrees(linkedRepoPath)
const mainWorktree = worktrees.find((worktree) => worktree.isMainWorktree)
expect(mainWorktree).toMatchObject({
path: repoPath,
isMainWorktree: true
})
}
)
it.skipIf(process.platform === 'win32')(
'leaves the main entry unchanged when scanned via a linked worktree',
async () => {
// A linked worktree has a `.git` *pointer file* just like a
// separate-git-dir checkout, but its porcelain main entry is the real
// main working root — never the Git directory. The git-common-dir gate
// must skip the rewrite so the main entry is not overwritten with the
// linked worktree's own toplevel (which would collide / drop the main).
const repoPath = await createNormalRepo()
const linkedWorktreePath = path.join(path.dirname(repoPath), 'linked')
git(repoPath, ['worktree', 'add', '--quiet', linkedWorktreePath, '-b', 'feature'])
const resolvedLinked = await realpath(linkedWorktreePath)
const worktrees = await listWorktrees(resolvedLinked)
const mainWorktree = worktrees.find((worktree) => worktree.isMainWorktree)
expect(mainWorktree).toMatchObject({
path: repoPath,
isMainWorktree: true
})
// The main entry must not be rewritten to the linked worktree's path.
expect(mainWorktree?.path).not.toBe(resolvedLinked)
}
)
it.skipIf(process.platform === 'win32')('does not throw for a bare repo', async () => {
const repoPath = await createBareRepo()
const worktrees = await listWorktrees(repoPath)
const mainWorktree = worktrees.find((worktree) => worktree.isMainWorktree)
expect(mainWorktree).toMatchObject({
path: repoPath,
isBare: true,
isMainWorktree: true
})
})
})
+117 -4
View File
@@ -14,6 +14,7 @@ import type {
RemoveWorktreeResult
} from '../../shared/types'
import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { gitExecFileAsync, translateWslOutputPaths } from './runner'
import { resolveGitDir } from './status'
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
@@ -29,6 +30,7 @@ type SparseWorktreeCreateError = Error & {
export type GitWorktreeExecOptions = {
wslDistro?: string
signal?: AbortSignal
}
export type AddWorktreeOptions = GitWorktreeExecOptions & {
@@ -69,8 +71,12 @@ const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8
function gitExecOptions(
cwd: string,
options: GitWorktreeExecOptions = {}
): { cwd: string; wslDistro?: string } {
return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd }
): { cwd: string; wslDistro?: string; signal?: AbortSignal } {
return {
cwd,
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
...(options.signal ? { signal: options.signal } : {})
}
}
function getErrorCode(error: unknown): string | undefined {
@@ -103,6 +109,12 @@ function isUnsupportedWorktreeListZError(error: unknown): boolean {
)
}
function isUnsupportedRevParsePathFormatError(error: unknown): boolean {
return /(?:unknown|invalid|unrecognized).*(?:--path-format|path-format)/i.test(
getErrorText(error)
)
}
function isBranchCheckedOutInWorktreeError(error: unknown): boolean {
return /cannot delete branch .*(?:used by worktree|checked out)|branch .*is checked out/i.test(
getErrorText(error)
@@ -348,6 +360,103 @@ function looksLikeWindowsPath(pathValue: string): boolean {
return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\')
}
function resolveRevParsePath(repoPath: string, value: string): string {
if (posix.isAbsolute(value) || win32.isAbsolute(value)) {
return value
}
// Old git ignores `--path-format=absolute`, so a relative toplevel/git-dir
// must be resolved against the scanned repo path.
return looksLikeWindowsPath(repoPath)
? win32.resolve(repoPath, value)
: posix.resolve(repoPath, value)
}
type RepoLocation = { topLevel: string; commonDir: string }
function parseRepoLocation(repoPath: string, output: string): RepoLocation | undefined {
// Old git (pre `--path-format`) echoes the unrecognized flag to stdout and
// exits 0 rather than erroring, so drop any echoed `-`-prefixed lines and
// read the two trailing path lines (toplevel, then git-common-dir). Strip only
// the trailing CR, not surrounding spaces — git paths may legitimately start
// or end with a space.
const lines = output
.split('\n')
.map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line))
.filter((line) => line.length > 0 && !line.startsWith('-'))
if (lines.length < 2) {
return undefined
}
const [topLevel, commonDir] = lines.slice(-2)
return {
topLevel: resolveRevParsePath(repoPath, topLevel),
commonDir: resolveRevParsePath(repoPath, commonDir)
}
}
async function readRepoLocation(
repoPath: string,
resolveBasePath: string,
options: GitWorktreeExecOptions = {}
): Promise<RepoLocation | undefined> {
try {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
gitExecOptions(repoPath, options)
)
return parseRepoLocation(resolveBasePath, stdout)
} catch (error) {
if (!isUnsupportedRevParsePathFormatError(error)) {
return undefined
}
}
try {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--show-toplevel', '--git-common-dir'],
gitExecOptions(repoPath, options)
)
return parseRepoLocation(resolveBasePath, stdout)
} catch {
return undefined
}
}
async function normalizeMainWorktreePath(
repoPath: string,
worktrees: GitWorktreeInfo[],
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
const mainIndex = worktrees.findIndex((worktree) => worktree.isMainWorktree)
const mainWorktree = worktrees[mainIndex]
// Compare in the Git-output space: under WSL the porcelain/rev-parse paths are
// Linux while repoPath is a UNC path, so without translating, a UNC repoPath
// never matches the early-return and fires a needless rev-parse on every poll.
// Use the platform-independent UNC parser so the comparison holds regardless
// of host OS; the runner still receives the original repoPath for WSL routing.
const wslRepo = parseWslUncPath(repoPath)
const comparablePath = wslRepo ? wslRepo.linuxPath : repoPath
if (!mainWorktree || areWorktreePathsEqual(mainWorktree.path, comparablePath)) {
return worktrees
}
const location = await readRepoLocation(repoPath, comparablePath, options)
if (!location) {
return worktrees
}
// Why: only a separate-git-dir/submodule main worktree reports the Git
// directory as the main entry — i.e. the main entry equals git-common-dir.
// A linked worktree's main entry is a real working root, so gating on this
// equality avoids overwriting it with the linked worktree's own toplevel.
if (!areWorktreePathsEqual(mainWorktree.path, location.commonDir)) {
return worktrees
}
const normalized = [...worktrees]
normalized[mainIndex] = { ...mainWorktree, path: location.topLevel }
return normalized
}
/**
* Parse the porcelain output of `git worktree list --porcelain`.
*/
@@ -441,7 +550,11 @@ async function readWorktreeList(
cwd: repoPath,
...options
})
return parseWorktreeList(stdout, { nulDelimited: true })
return normalizeMainWorktreePath(
repoPath,
parseWorktreeList(stdout, { nulDelimited: true }),
options
)
} catch (error) {
if (!isUnsupportedWorktreeListZError(error)) {
throw error
@@ -454,7 +567,7 @@ async function readWorktreeList(
cwd: repoPath,
...options
})
return parseWorktreeList(stdout)
return normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout), options)
}
async function readTranslatedWorktreeGraph(
+34
View File
@@ -1020,6 +1020,40 @@ describe('runHook', () => {
})
})
describe('createSetupRunnerScript', () => {
const makeRepo = (setupAgentStartupPolicy?: 'start-immediately' | 'wait-for-setup') =>
({
id: 'test-id',
path: '/test/repo',
displayName: 'Test Repo',
badgeColor: '#000',
addedAt: Date.now(),
hookSettings: {
mode: 'auto',
setupAgentStartupPolicy,
scripts: { setup: '', archive: '' }
}
}) as unknown as Repo
it('omits waitForAgentStartup unless the repo explicitly waits for setup', async () => {
gitExecFileSyncMock.mockReset()
gitExecFileSyncMock.mockReturnValue('/test/repo/.git/orca/setup-runner.sh\n')
const { createSetupRunnerScript } = await import('./hooks')
expect(
createSetupRunnerScript(makeRepo(), '/test/worktree', 'echo setup').waitForAgentStartup
).toBeUndefined()
expect(
createSetupRunnerScript(makeRepo('start-immediately'), '/test/worktree', 'echo setup')
.waitForAgentStartup
).toBeUndefined()
expect(
createSetupRunnerScript(makeRepo('wait-for-setup'), '/test/worktree', 'echo setup')
.waitForAgentStartup
).toBe(true)
})
})
describe('shouldRunSetupForCreate', () => {
const makeRepo = (setupRunPolicy?: 'ask' | 'run-by-default' | 'skip-by-default') =>
({
+10 -3
View File
@@ -6,6 +6,7 @@ import { parse } from 'yaml'
import { getDefaultRepoHookSettings } from '../shared/constants'
import { getRuntimePathBasename } from '../shared/cross-platform-path'
import { resolveHookCommandSourcePolicy } from '../shared/hook-command-source-policy'
import { shouldWaitForSetupBeforeAgentStartup } from '../shared/setup-agent-startup-policy'
import { gitExecFileSync } from './git/runner'
import { isWslPath, parseWslPath, toWindowsWslPath, toLinuxPath } from './wsl'
import type {
@@ -517,7 +518,8 @@ export function createSetupRunnerScript(
worktreePath,
script,
'setup-runner',
getHookRuntimeTarget(projectRuntime)
getHookRuntimeTarget(projectRuntime),
shouldWaitForSetupBeforeAgentStartup(repo.hookSettings?.setupAgentStartupPolicy)
)
}
@@ -575,7 +577,8 @@ function createWorktreeRunnerScript(
worktreePath: string,
script: string,
runnerBaseName: 'setup-runner' | 'issue-command-runner',
runtimeTarget?: HookRuntimeTarget
runtimeTarget?: HookRuntimeTarget,
waitForAgentStartup?: boolean
): WorktreeSetupLaunch {
const envVars = getSetupEnvVars(repo, worktreePath)
// Why: WSL worktrees run on a Linux filesystem even though process.platform
@@ -618,7 +621,11 @@ function createWorktreeRunnerScript(
}
}
return { runnerScriptPath, envVars }
return {
runnerScriptPath,
envVars,
...(waitForAgentStartup === true ? { waitForAgentStartup: true } : {})
}
}
/**
+11 -3
View File
@@ -17,6 +17,7 @@ import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/stor
import { killAllPty } from './ipc/pty'
import { initDaemonPtyProvider, disconnectDaemon, shutdownDaemon } from './daemon/daemon-init'
import { closeAllWatchers } from './ipc/filesystem-watcher'
import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher'
import { registerCoreHandlers } from './ipc/register-core-handlers'
import { initObservability, shutdownObservability } from './observability'
import { startSpan } from './observability/tracer'
@@ -986,9 +987,16 @@ function shutdownWatchersOnce(): Promise<void> {
if (!watcherShutdownPromise) {
// Why: @parcel/watcher tears down native async work during unsubscribe.
// Electron must wait for that cleanup before Node's environment exits.
watcherShutdownPromise = closeAllWatchers()
.catch((error) => {
console.error('[filesystem-watcher] shutdown failed:', error)
watcherShutdownPromise = Promise.allSettled([
closeAllWatchers(),
disposeWorktreeBaseDirectoryWatchers()
])
.then((results) => {
for (const result of results) {
if (result.status === 'rejected') {
console.error('[filesystem-watcher] shutdown failed:', result.reason)
}
}
})
.then(() => {
watcherShutdownDone = true
+61
View File
@@ -172,6 +172,7 @@ vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({
}))
import { LocalPtyProvider } from '../providers/local-pty-provider'
import { makePaneKey } from '../../shared/stable-pane-id'
import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing'
import {
registerPtyHandlers,
registerSshPtyProvider,
@@ -796,6 +797,20 @@ describe('registerPtyHandlers', () => {
}
)
it('uses sequenced startup env as the MiMo launch hint when command is a wrapper', async () => {
const env = await spawnAndGetEnv(
{ [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'mimo --prompt hi' },
undefined,
undefined,
undefined,
'bash -lc wait-wrapper'
)
expect(mimoCodeBuildPtyEnvMock).toHaveBeenCalledTimes(1)
expect(env.MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared')
expect(env.ORCA_MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared')
})
it('does not inject MiMo overlay for non-mimo launches', async () => {
await spawnAndGetEnv()
@@ -897,6 +912,29 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
})
it('uses sequenced startup env as the OMP launch hint when command is a wrapper', async () => {
const env = await spawnAndGetEnv(
{
PI_CODING_AGENT_DIR: '/tmp/user-omp-agent',
[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume'
},
undefined,
undefined,
undefined,
'powershell wait-wrapper'
)
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/tmp/user-omp-agent',
'omp'
)
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(
'/tmp/user-omp-agent/extensions/orca-agent-status.ts'
)
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
})
it('mirrors the original Pi source dir when launched from an Orca overlay shell', async () => {
const env = await spawnAndGetEnv({
PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay',
@@ -1347,6 +1385,29 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
})
it('uses sequenced startup env as the daemon OMP launch hint when command is a wrapper', async () => {
const env = await daemonSpawnAndGetEnv(
{
PI_CODING_AGENT_DIR: '/user/.omp/agent',
[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume'
},
undefined,
undefined,
undefined,
{ command: 'powershell wait-wrapper' }
)
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/user/.omp/agent',
'omp'
)
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(
'/user/.omp/agent/extensions/orca-agent-status.ts'
)
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
})
it('injects the selected Codex home on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({}, () => TEST_CODEX_HOME)
expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME)
+12 -3
View File
@@ -86,6 +86,7 @@ import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy'
import { resolveSetupAgentSequenceLaunchCommand } from '../../shared/setup-agent-sequencing'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
import {
assertFolderWorkspacePathUsable,
@@ -751,9 +752,10 @@ export function buildPtyHostEnv(
// in lock-step across spawn paths without pushing process.env onto the
// IPC wire unnecessarily.
const preexistingOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv)
const piAgentKind = detectPiAgentKindFromCommand(opts.launchCommand)
const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand)
const piAgentKind = detectPiAgentKindFromCommand(launchCommandHint)
const hasLaunchCommand =
typeof opts.launchCommand === 'string' && opts.launchCommand.trim().length > 0
typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0
const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand
// Why: source shadows are agent-scoped. Trusting the other kind's source
// would reintroduce the exact Pi/OMP extension-state shadowing this PR fixes.
@@ -783,7 +785,7 @@ export function buildPtyHostEnv(
delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR
}
}
if (isMimoLaunchCommand(opts.launchCommand)) {
if (isMimoLaunchCommand(launchCommandHint)) {
const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv)
Object.assign(baseEnv, mimoCodeHookService.buildPtyEnv(id, preexistingMimocodeHome))
if (baseEnv.MIMOCODE_HOME) {
@@ -1880,6 +1882,9 @@ export function registerPtyHandlers(
if (args.command !== undefined) {
spawnOptions.command = args.command
}
if (args.commandDelivery !== undefined) {
spawnOptions.commandDelivery = args.commandDelivery
}
if (args.startupCommandDelivery !== undefined) {
spawnOptions.startupCommandDelivery = args.startupCommandDelivery
}
@@ -2256,6 +2261,7 @@ export function registerPtyHandlers(
env?: Record<string, string>
envToDelete?: string[]
command?: string
commandDelivery?: 'renderer' | 'provider'
launchConfig?: SleepingAgentLaunchConfig
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
@@ -2557,6 +2563,9 @@ export function registerPtyHandlers(
if (args.command !== undefined) {
spawnOptions.command = args.command
}
if (args.commandDelivery !== undefined) {
spawnOptions.commandDelivery = args.commandDelivery
}
if (args.startupCommandDelivery !== undefined) {
spawnOptions.startupCommandDelivery = args.startupCommandDelivery
}
+42 -1
View File
@@ -79,6 +79,7 @@ import { getSshGitUsername } from '../git/git-username'
import { getActiveMultiplexer } from './ssh'
import { normalizeSparseDirectories } from './sparse-checkout-directories'
import { track } from '../telemetry/client'
import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher'
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
import type { RepoMethod } from '../../shared/telemetry-events'
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
@@ -1926,10 +1927,17 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
| 'forkSyncMode'
| 'externalWorktreeVisibility'
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
| 'projectGroupId'
| 'projectGroupOrder'
>
> & { sourceControlAi?: Repo['sourceControlAi'] | null }
> & {
sourceControlAi?: Repo['sourceControlAi'] | null
externalWorktreeDiscoverySuppressedAt?:
| Repo['externalWorktreeDiscoverySuppressedAt']
| null
}
}
) => {
// Why: validate the persisted preference string at the IPC boundary
@@ -2009,6 +2017,38 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
) {
delete updates.externalWorktreeVisibilityPromptDismissedAt
}
// Why: null is the transport sentinel for clearing discovery suppression.
if (
'externalWorktreeDiscoverySuppressedAt' in updates &&
updates.externalWorktreeDiscoverySuppressedAt === null
) {
updates.externalWorktreeDiscoverySuppressedAt = undefined
} else if (
'externalWorktreeDiscoverySuppressedAt' in updates &&
updates.externalWorktreeDiscoverySuppressedAt !== undefined &&
(typeof updates.externalWorktreeDiscoverySuppressedAt !== 'number' ||
!Number.isFinite(updates.externalWorktreeDiscoverySuppressedAt))
) {
delete updates.externalWorktreeDiscoverySuppressedAt
}
if (
'externalWorktreeInboxBaselinePaths' in updates &&
updates.externalWorktreeInboxBaselinePaths !== undefined
) {
const value = updates.externalWorktreeInboxBaselinePaths as unknown
if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) {
delete updates.externalWorktreeInboxBaselinePaths
}
}
if (
'importedExternalWorktreePaths' in updates &&
updates.importedExternalWorktreePaths !== undefined
) {
const value = updates.importedExternalWorktreePaths as unknown
if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) {
delete updates.importedExternalWorktreePaths
}
}
// Why: null is the transport sentinel for clearing Source Control AI.
// Other invalid fields are deleted; this one must flow as undefined.
if ('sourceControlAi' in updates && updates.sourceControlAi === null) {
@@ -2525,6 +2565,7 @@ function notifyReposChanged(mainWindow: BrowserWindow): void {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('repos:changed')
}
scheduleCurrentWorktreeBaseDirectoryWatcherSync()
}
function notifySparsePresetsChanged(mainWindow: BrowserWindow, repoId: string): void {
+2
View File
@@ -18,6 +18,7 @@ import { normalizeUiLanguage } from '../../shared/ui-language'
import { applyAppIcon } from '../app-icon'
import { normalizeTerminalCustomThemes } from '../../shared/terminal-custom-themes'
import { prepareLocalWorktreeRootsForRepos } from '../worktree-root-preparation'
import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher'
// Why: the whitelist is the source-of-truth for which keys we emit on. Casting
// to a Set once at module load lets the IPC handler's per-key membership
@@ -114,6 +115,7 @@ export function registerSettingsHandlers(
('nestWorkspaces' in sanitizedArgs && before.nestWorkspaces !== result.nestWorkspaces)
) {
void prepareLocalWorktreeRootsForRepos(store)
scheduleCurrentWorktreeBaseDirectoryWatcherSync()
}
if (APPEARANCE_MENU_KEYS.some((key) => key in sanitizedArgs)) {
rebuildAppMenu()
+213
View File
@@ -0,0 +1,213 @@
import type { IGitProvider } from '../providers/types'
import { isFolderRepo } from '../../shared/repo-kind'
import type { Repo, Worktree } from '../../shared/types'
import {
applyWorkspaceCleanupPolicy,
createWorkspaceCleanupFingerprint,
getWorkspaceCleanupInactivityReasons,
isWorkspaceOldForCleanup,
type WorkspaceCleanupBlocker,
type WorkspaceCleanupCandidate,
type WorkspaceCleanupReason
} from '../../shared/workspace-cleanup'
import {
createEmptyWorkspaceCleanupGitEvidence,
readWorkspaceCleanupGitEvidence
} from './workspace-cleanup-git-evidence'
import { appendWorkspaceCleanupItems } from './workspace-cleanup-scan-primitives'
export async function buildWorkspaceCleanupCandidate(args: {
repo: Repo
worktree: Worktree
scannedAt: number
provider: IGitProvider | null
skipGit: boolean
forceGitCheck: boolean
}): Promise<WorkspaceCleanupCandidate> {
const { repo, worktree, scannedAt, provider, skipGit, forceGitCheck } = args
const blockers: WorkspaceCleanupBlocker[] = []
const reasons = getWorkspaceCleanupInactivityReasonsForWorkspace(worktree, scannedAt)
const repoIsFolder = isFolderRepo(repo)
if (worktree.isMainWorktree) {
blockers.push('main-worktree')
}
if (repoIsFolder) {
blockers.push('folder-repo')
}
if (worktree.isPinned) {
blockers.push('pinned')
}
const localContext = buildWorkspaceCleanupLocalContext(worktree)
const shouldReadGit = shouldReadWorkspaceCleanupGitEvidence({
repoIsFolder,
blockers,
worktree,
skipGit,
forceGitCheck
})
const gitEvidence = !shouldReadGit
? createEmptyWorkspaceCleanupGitEvidence()
: await readWorkspaceCleanupGitEvidence(worktree, repo, provider)
appendWorkspaceCleanupItems(blockers, gitEvidence.blockers)
const candidateWithoutFingerprint: WorkspaceCleanupCandidate = {
worktreeId: worktree.id,
repoId: repo.id,
repoName: repo.displayName,
connectionId: repo.connectionId ?? null,
displayName: worktree.displayName,
branch: shortWorkspaceCleanupBranchName(worktree.branch),
path: worktree.path,
tier: 'review',
selectedByDefault: false,
reasons,
blockers: uniqueWorkspaceCleanupBlockers(blockers),
lastActivityAt: worktree.lastActivityAt,
...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}),
localContext,
git: {
clean: gitEvidence.clean,
upstreamAhead: gitEvidence.upstreamAhead,
upstreamBehind: gitEvidence.upstreamBehind,
checkedAt: gitEvidence.checkedAt
},
fingerprint: ''
}
const fingerprint = createWorkspaceCleanupFingerprint({
branch: candidateWithoutFingerprint.branch,
head: worktree.head,
gitClean: gitEvidence.clean,
lastActivityAt: worktree.lastActivityAt
})
return applyWorkspaceCleanupPolicy({
...candidateWithoutFingerprint,
reasons: uniqueWorkspaceCleanupReasons(reasons),
blockers: uniqueWorkspaceCleanupBlockers(blockers),
fingerprint
})
}
export function buildWorkspaceCleanupCandidateFromError(
repo: Repo,
worktree: Worktree,
scannedAt: number
): WorkspaceCleanupCandidate {
return applyWorkspaceCleanupPolicy({
worktreeId: worktree.id,
repoId: repo.id,
repoName: repo.displayName,
connectionId: repo.connectionId ?? null,
displayName: worktree.displayName,
branch: shortWorkspaceCleanupBranchName(worktree.branch),
path: worktree.path,
tier: 'protected',
selectedByDefault: false,
reasons: getWorkspaceCleanupInactivityReasonsForWorkspace(worktree, scannedAt),
blockers: ['git-status-error'],
lastActivityAt: worktree.lastActivityAt,
...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}),
localContext: buildWorkspaceCleanupLocalContext(worktree),
git: {
clean: null,
upstreamAhead: null,
upstreamBehind: null,
checkedAt: scannedAt
},
fingerprint: createWorkspaceCleanupFingerprint({
branch: shortWorkspaceCleanupBranchName(worktree.branch),
head: worktree.head,
gitClean: null,
lastActivityAt: worktree.lastActivityAt
})
})
}
export function buildWorkspaceCleanupLocalContext(
worktree: Pick<Worktree, 'diffComments'>
): WorkspaceCleanupCandidate['localContext'] {
return {
terminalTabCount: 0,
cleanEditorTabCount: 0,
browserTabCount: 0,
diffCommentCount: worktree.diffComments?.length ?? 0,
newestDiffCommentAt: getNewestWorkspaceCleanupDiffCommentAt(worktree.diffComments),
retainedDoneAgentCount: 0
}
}
export function getNewestWorkspaceCleanupDiffCommentAt(
diffComments: Worktree['diffComments'] | undefined
): number | null {
if (!diffComments || diffComments.length === 0) {
return null
}
// Why: persisted diff notes can grow large enough for spread-based Math.max
// to exceed the JavaScript argument limit during cleanup scans.
let newest = diffComments[0]?.createdAt ?? null
for (let index = 1; index < diffComments.length; index += 1) {
const createdAt = diffComments[index]?.createdAt
if (createdAt !== undefined && (newest === null || createdAt > newest)) {
newest = createdAt
}
}
return newest
}
export function isWorkspaceInactiveForCleanup(
workspace: Pick<Worktree, 'isArchived' | 'lastActivityAt'>,
scannedAt: number
): boolean {
return isWorkspaceOldForCleanup(workspace, scannedAt)
}
export function getWorkspaceCleanupInactivityReasonsForWorkspace(
workspace: Pick<Worktree, 'isArchived' | 'lastActivityAt'>,
scannedAt: number
): WorkspaceCleanupReason[] {
return getWorkspaceCleanupInactivityReasons(workspace, scannedAt)
}
export function shortWorkspaceCleanupBranchName(branch: string): string {
return branch.replace(/^refs\/heads\//, '') || 'HEAD'
}
function shouldReadWorkspaceCleanupGitEvidence(args: {
repoIsFolder: boolean
blockers: WorkspaceCleanupBlocker[]
worktree: Worktree
skipGit: boolean
forceGitCheck: boolean
}): boolean {
const { repoIsFolder, blockers, worktree, skipGit, forceGitCheck } = args
if ((skipGit && !forceGitCheck) || repoIsFolder || worktree.isMainWorktree) {
return false
}
if (
blockers.includes('pinned') ||
blockers.includes('main-worktree') ||
blockers.includes('folder-repo')
) {
return false
}
// Why: inactivity is the only recommendation signal now. Git is read only
// to keep the destructive path from deleting dirty or local-only branch work.
return true
}
function uniqueWorkspaceCleanupBlockers(
blockers: WorkspaceCleanupBlocker[]
): WorkspaceCleanupBlocker[] {
return [...new Set(blockers)]
}
function uniqueWorkspaceCleanupReasons(
reasons: WorkspaceCleanupReason[]
): WorkspaceCleanupReason[] {
return [...new Set(reasons)]
}
@@ -0,0 +1,92 @@
import { basename } from 'node:path'
import type { Store } from '../persistence'
import type { Repo, WorktreeMeta } from '../../shared/types'
import {
applyWorkspaceCleanupPolicy,
createWorkspaceCleanupFingerprint,
type WorkspaceCleanupCandidate
} from '../../shared/workspace-cleanup'
import { splitWorktreeId } from '../../shared/worktree-id'
import {
getNewestWorkspaceCleanupDiffCommentAt,
getWorkspaceCleanupInactivityReasonsForWorkspace,
isWorkspaceInactiveForCleanup
} from './workspace-cleanup-candidate'
export function synthesizeDisconnectedSshCleanupCandidates(
store: Store,
repo: Repo,
scannedAt: number,
targetWorktreeId?: string
): WorkspaceCleanupCandidate[] {
const repoWorktreePrefix = `${repo.id}::`
if (targetWorktreeId) {
if (!targetWorktreeId.startsWith(repoWorktreePrefix)) {
return []
}
// Why: focused delete preflight names one workspace already; walking all
// persisted metadata is unnecessary for disconnected SSH repos.
const meta = store.getWorktreeMeta(targetWorktreeId)
return meta ? [createDisconnectedSshCandidate(repo, scannedAt, targetWorktreeId, meta)] : []
}
const candidates: WorkspaceCleanupCandidate[] = []
const allMeta = store.getAllWorktreeMeta()
for (const worktreeId in allMeta) {
if (!Object.hasOwn(allMeta, worktreeId) || !worktreeId.startsWith(repoWorktreePrefix)) {
continue
}
const meta = allMeta[worktreeId]
if (!meta || !isWorkspaceInactiveForCleanup(meta, scannedAt)) {
continue
}
candidates.push(createDisconnectedSshCandidate(repo, scannedAt, worktreeId, meta))
}
return candidates
}
function createDisconnectedSshCandidate(
repo: Repo,
scannedAt: number,
worktreeId: string,
meta: WorktreeMeta
): WorkspaceCleanupCandidate {
const parsed = splitWorktreeId(worktreeId)
const path = parsed?.worktreePath ?? worktreeId
const reasons = getWorkspaceCleanupInactivityReasonsForWorkspace(meta, scannedAt)
return applyWorkspaceCleanupPolicy({
worktreeId,
repoId: repo.id,
repoName: repo.displayName,
connectionId: repo.connectionId ?? null,
displayName: meta.displayName || basename(path),
branch: basename(path),
path,
tier: 'protected',
selectedByDefault: false,
reasons,
blockers: ['ssh-disconnected'],
lastActivityAt: meta.lastActivityAt,
...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}),
localContext: {
terminalTabCount: 0,
cleanEditorTabCount: 0,
browserTabCount: 0,
diffCommentCount: meta.diffComments?.length ?? 0,
newestDiffCommentAt: getNewestWorkspaceCleanupDiffCommentAt(meta.diffComments),
retainedDoneAgentCount: 0
},
git: {
clean: null,
upstreamAhead: null,
upstreamBehind: null,
checkedAt: null
},
fingerprint: createWorkspaceCleanupFingerprint({
branch: basename(path),
head: '',
gitClean: null,
lastActivityAt: meta.lastActivityAt
})
})
}
@@ -0,0 +1,119 @@
import { getStatus } from '../git/status'
import { gitExecFileAsync } from '../git/runner'
import type { IGitProvider } from '../providers/types'
import type { GitStatusResult, Repo, Worktree } from '../../shared/types'
import type { WorkspaceCleanupBlocker } from '../../shared/workspace-cleanup'
import {
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
withWorkspaceCleanupTimeout
} from './workspace-cleanup-scan-primitives'
export type WorkspaceCleanupGitEvidence = {
clean: boolean | null
upstreamAhead: number | null
upstreamBehind: number | null
checkedAt: number | null
blockers: WorkspaceCleanupBlocker[]
}
export function createEmptyWorkspaceCleanupGitEvidence(): WorkspaceCleanupGitEvidence {
return {
clean: null,
upstreamAhead: null,
upstreamBehind: null,
checkedAt: null,
blockers: []
}
}
export async function readWorkspaceCleanupGitEvidence(
worktree: Worktree,
repo: Repo,
provider: IGitProvider | null
): Promise<WorkspaceCleanupGitEvidence> {
const blockers: WorkspaceCleanupBlocker[] = []
let status: GitStatusResult
const checkedAt = Date.now()
try {
status = await withWorkspaceCleanupTimeout(
(signal) =>
repo.connectionId
? provider!.getStatus(worktree.path, { signal })
: getStatus(worktree.path, { signal }),
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
'Timed out reading git status.'
)
} catch {
return {
...createEmptyWorkspaceCleanupGitEvidence(),
blockers: ['git-status-error']
}
}
if (status.upstreamStatus === undefined) {
return {
...createEmptyWorkspaceCleanupGitEvidence(),
blockers: ['git-status-error']
}
}
const clean = status.entries.length === 0
if (!clean) {
blockers.push('dirty-files')
}
const upstreamAhead = status.upstreamStatus.hasUpstream ? status.upstreamStatus.ahead : null
const upstreamBehind = status.upstreamStatus.hasUpstream ? status.upstreamStatus.behind : null
if (upstreamAhead !== null && upstreamAhead > 0) {
blockers.push('unpushed-commits')
}
if (clean && upstreamAhead === null) {
const unpushedCommitCount = await readUnpushedCommitCount(worktree, repo, provider)
if (unpushedCommitCount === null) {
blockers.push('unknown-base')
} else if (unpushedCommitCount > 0) {
blockers.push('unpushed-commits')
}
}
return {
clean,
upstreamAhead,
upstreamBehind,
checkedAt,
blockers: uniqueWorkspaceCleanupGitBlockers(blockers)
}
}
async function readUnpushedCommitCount(
worktree: Worktree,
repo: Repo,
provider: IGitProvider | null
): Promise<number | null> {
try {
const result = await withWorkspaceCleanupTimeout(
(signal) =>
repo.connectionId
? provider!.exec(['rev-list', '--count', 'HEAD', '--not', '--remotes'], worktree.path, {
signal
})
: gitExecFileAsync(['rev-list', '--count', 'HEAD', '--not', '--remotes'], {
cwd: worktree.path,
signal
}),
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
'Timed out checking unpushed commits.'
)
const count = Number.parseInt(result.stdout.trim(), 10)
return Number.isFinite(count) ? count : null
} catch {
return null
}
}
function uniqueWorkspaceCleanupGitBlockers(
blockers: WorkspaceCleanupBlocker[]
): WorkspaceCleanupBlocker[] {
return [...new Set(blockers)]
}
@@ -0,0 +1,102 @@
import { basename } from 'node:path'
import type { Repo } from '../../shared/types'
import type { WorkspaceCleanupScanError } from '../../shared/workspace-cleanup'
export const WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS = 8_000
export function appendWorkspaceCleanupItems<T>(target: T[], entries: readonly T[]): void {
// Why: cleanup can aggregate generated-size worktree batches; spreading
// those batches into push can exceed JavaScript's argument limit.
for (const entry of entries) {
target.push(entry)
}
}
export async function mapWorkspaceCleanupWithConcurrency<T, R>(
items: readonly T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> {
const results: R[] = []
let nextIndex = 0
const workerCount = Math.min(limit, items.length)
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const index = nextIndex
nextIndex += 1
results[index] = await fn(items[index])
}
})
)
return results
}
export async function withWorkspaceCleanupTimeout<T>(
run: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
message: string
): Promise<T> {
const controller = new AbortController()
let timeoutId: NodeJS.Timeout | undefined
const timeoutPromise = new Promise<T>((_, reject) => {
timeoutId = setTimeout(() => {
controller.abort()
reject(new Error(message))
}, timeoutMs)
})
try {
return await Promise.race([run(controller.signal), timeoutPromise])
} finally {
if (timeoutId) {
clearTimeout(timeoutId)
}
}
}
export function toWorkspaceCleanupErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
export function createWorkspaceCleanupScanError(
repo: Repo,
message: string
): WorkspaceCleanupScanError {
return {
repoId: repo.id,
repoName: repo.displayName || basename(repo.path),
message
}
}
// Why: git errors often include absolute paths or command output. Keep the
// cause useful without leaking raw local/remote filesystem details to the UI.
export function toSafeWorkspaceCleanupRepoScanError(error: unknown): string {
const message = toWorkspaceCleanupErrorMessage(error)
if (message === 'Timed out listing SSH worktrees.') {
return 'Timed out listing remote worktrees.'
}
if (message === 'Timed out listing worktrees.') {
return 'Timed out listing worktrees.'
}
if (message.startsWith('Timed out ')) {
return message.replace(/\.$/, '')
}
const lower = message.toLowerCase()
if (lower.includes('not a git repository') || lower.includes('not a git worktree')) {
return 'Repository is not a git checkout.'
}
if (
lower.includes('enoent') ||
lower.includes('no such file') ||
lower.includes('cannot find') ||
lower.includes('does not exist')
) {
return 'Repository folder was not found.'
}
if (lower.includes('eacces') || lower.includes('eperm') || lower.includes('permission denied')) {
return 'Repository folder is not accessible.'
}
return 'Git could not list worktrees.'
}
+259
View File
@@ -0,0 +1,259 @@
import type { Store } from '../persistence'
import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import type { IGitProvider } from '../providers/types'
import { isFolderRepo } from '../../shared/repo-kind'
import type { GitWorktreeInfo, Repo } from '../../shared/types'
import { mergeWorktree } from './worktree-logic'
import { splitWorktreeId } from '../../shared/worktree-id'
import type {
WorkspaceCleanupCandidate,
WorkspaceCleanupScanError,
WorkspaceCleanupScanArgs,
WorkspaceCleanupScanProgress,
WorkspaceCleanupScanResult
} from '../../shared/workspace-cleanup'
import {
buildWorkspaceCleanupCandidate,
buildWorkspaceCleanupCandidateFromError,
isWorkspaceInactiveForCleanup
} from './workspace-cleanup-candidate'
import { synthesizeDisconnectedSshCleanupCandidates } from './workspace-cleanup-disconnected-ssh'
import {
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
appendWorkspaceCleanupItems,
createWorkspaceCleanupScanError,
mapWorkspaceCleanupWithConcurrency,
toSafeWorkspaceCleanupRepoScanError,
withWorkspaceCleanupTimeout
} from './workspace-cleanup-scan-primitives'
const WORKTREE_SCAN_CONCURRENCY = 3
type WorkspaceCleanupScanOptions = {
onProgress?: (progress: WorkspaceCleanupScanProgress) => void
}
type WorkspaceCleanupScanRepoProgress = {
onWorktreesDiscovered?: (count: number) => void
onCandidateScanned?: (candidate: WorkspaceCleanupCandidate) => void
onErrors?: (errors: WorkspaceCleanupScanError[]) => void
}
type WorkspaceCleanupProgressEmitter = {
addDiscovered: (count: number) => void
addCandidate: (candidate: WorkspaceCleanupCandidate) => void
addErrors: (errors: WorkspaceCleanupScanError[]) => void
}
export async function scanWorkspaceCleanup(
store: Store,
args: WorkspaceCleanupScanArgs = {},
options: WorkspaceCleanupScanOptions = {}
): Promise<WorkspaceCleanupScanResult> {
const scannedAt = Date.now()
const parsedTarget = args.worktreeId ? splitWorktreeId(args.worktreeId) : null
if (args.worktreeId && !parsedTarget) {
return { scannedAt, candidates: [], errors: [] }
}
const repos = parsedTarget
? store.getRepos().filter((repo) => repo.id === parsedTarget.repoId)
: store.getRepos()
const progress = createProgressEmitter(args.scanId, scannedAt, options)
const errors: WorkspaceCleanupScanResult['errors'] = []
const candidates: WorkspaceCleanupCandidate[] = []
for (const repo of repos) {
const result = await scanRepoWorkspaces({
store,
repo,
scannedAt,
targetWorktreeId: args.worktreeId,
skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? []),
onWorktreesDiscovered: progress.addDiscovered,
onCandidateScanned: progress.addCandidate,
onErrors: progress.addErrors
})
appendWorkspaceCleanupItems(candidates, result.candidates)
appendWorkspaceCleanupItems(errors, result.errors)
}
return { scannedAt, candidates, errors }
}
async function scanRepoWorkspaces(
args: {
store: Store
repo: Repo
scannedAt: number
targetWorktreeId?: string
skipGitWorktreeIds: Set<string>
} & WorkspaceCleanupScanRepoProgress
): Promise<WorkspaceCleanupScanResult> {
const {
store,
repo,
scannedAt,
targetWorktreeId,
skipGitWorktreeIds,
onWorktreesDiscovered,
onCandidateScanned,
onErrors
} = args
const errors: WorkspaceCleanupScanResult['errors'] = []
const repoIsFolder = isFolderRepo(repo)
let provider: IGitProvider | null = null
let gitWorktrees: GitWorktreeInfo[] = []
try {
const discovered = await listCleanupGitWorktrees(repo, repoIsFolder)
provider = discovered.provider
gitWorktrees = discovered.gitWorktrees
} catch (error) {
return handleRepoWorktreeListError({ repo, targetWorktreeId, scannedAt, error, onErrors })
}
if (repo.connectionId && !provider) {
const candidates = targetWorktreeId
? synthesizeDisconnectedSshCleanupCandidates(store, repo, scannedAt, targetWorktreeId)
: []
onWorktreesDiscovered?.(candidates.length)
for (const candidate of candidates) {
onCandidateScanned?.(candidate)
}
return { scannedAt, candidates, errors: [] }
}
const worktrees = gitWorktrees
.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
const meta = store.getWorktreeMeta(worktreeId)
return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
})
.filter((worktree) => {
if (targetWorktreeId) {
return worktree.id === targetWorktreeId
}
return (
!repoIsFolder &&
!worktree.isMainWorktree &&
isWorkspaceInactiveForCleanup(worktree, scannedAt)
)
})
onWorktreesDiscovered?.(worktrees.length)
const candidates = await mapWorkspaceCleanupWithConcurrency(
worktrees,
WORKTREE_SCAN_CONCURRENCY,
async (worktree) => {
const candidate = await buildWorkspaceCleanupCandidate({
repo,
worktree,
scannedAt,
provider,
skipGit: skipGitWorktreeIds.has(worktree.id),
forceGitCheck: Boolean(targetWorktreeId)
}).catch((error) => {
console.error('Workspace cleanup candidate scan failed', error)
return buildWorkspaceCleanupCandidateFromError(repo, worktree, scannedAt)
})
onCandidateScanned?.(candidate)
return candidate
}
)
return { scannedAt, candidates, errors }
}
async function listCleanupGitWorktrees(
repo: Repo,
repoIsFolder: boolean
): Promise<{ provider: IGitProvider | null; gitWorktrees: GitWorktreeInfo[] }> {
if (repoIsFolder) {
return { provider: null, gitWorktrees: [createFolderWorktree(repo)] }
}
if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId) ?? null
if (!provider) {
// Why: cleanup should reflect only workspaces Orca can currently inspect.
return { provider: null, gitWorktrees: [] }
}
return {
provider,
gitWorktrees: await withWorkspaceCleanupTimeout(
(signal) => provider.listWorktrees(repo.path, { signal }),
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
'Timed out listing SSH worktrees.'
)
}
}
return {
provider: null,
gitWorktrees: await withWorkspaceCleanupTimeout(
(signal) => listRepoWorktrees(repo, { signal }),
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
'Timed out listing worktrees.'
)
}
}
function handleRepoWorktreeListError(args: {
repo: Repo
targetWorktreeId?: string
scannedAt: number
error: unknown
onErrors?: (errors: WorkspaceCleanupScanError[]) => void
}): WorkspaceCleanupScanResult {
const { repo, targetWorktreeId, scannedAt, error, onErrors } = args
console.error('Workspace cleanup repo scan failed', error)
if (repo.connectionId && !targetWorktreeId) {
// Why: broad cleanup only shows remote workspaces Orca can inspect now.
// A connected SSH repo that fails mid-scan is omitted, not bannered.
return { scannedAt, candidates: [], errors: [] }
}
const errors = [createWorkspaceCleanupScanError(repo, toSafeWorkspaceCleanupRepoScanError(error))]
onErrors?.(errors)
return { scannedAt, candidates: [], errors }
}
function createProgressEmitter(
scanId: string | undefined,
scannedAt: number,
options: WorkspaceCleanupScanOptions
): WorkspaceCleanupProgressEmitter {
const errors: WorkspaceCleanupScanError[] = []
let totalWorktreeCount = 0
let scannedWorktreeCount = 0
const emit = (
candidates: WorkspaceCleanupCandidate[],
candidateMode: WorkspaceCleanupScanProgress['candidateMode'] = 'snapshot'
): void => {
if (!scanId) {
return
}
options.onProgress?.({
scanId,
scannedAt,
totalWorktreeCount,
scannedWorktreeCount,
candidates,
errors: [...errors],
candidateMode
})
}
return {
addDiscovered: (count) => {
totalWorktreeCount += count
emit([], 'append')
},
addCandidate: (candidate) => {
scannedWorktreeCount += 1
emit([candidate], 'append')
},
addErrors: (newErrors) => {
appendWorkspaceCleanupItems(errors, newErrors)
emit([], 'append')
}
}
}
+133 -4
View File
@@ -10,6 +10,7 @@ import type {
Repo,
WorktreeMeta
} from '../../shared/types'
import type { WorkspaceCleanupScanProgress } from '../../shared/workspace-cleanup'
const {
listRepoWorktreesMock,
@@ -171,6 +172,58 @@ describe('workspace cleanup scan', () => {
})
})
it('reports cleanup scan progress as inactive workspaces are checked', async () => {
listRepoWorktreesMock.mockResolvedValue([
{
path: '/repo-feature-a',
head: 'abc123',
branch: 'refs/heads/feature-a',
isBare: false,
isMainWorktree: false
},
{
path: '/repo-feature-b',
head: 'def456',
branch: 'refs/heads/feature-b',
isBare: false,
isMainWorktree: false
}
])
const progress: unknown[] = []
const result = await scanWorkspaceCleanup(
makeStore(),
{ scanId: 'scan-1' },
{ onProgress: (event) => progress.push(event) }
)
expect(result.candidates).toHaveLength(2)
expect(progress[0]).toMatchObject({
scanId: 'scan-1',
scannedWorktreeCount: 0,
totalWorktreeCount: 2,
candidates: []
})
const candidateProgress = progress.filter(
(event): event is WorkspaceCleanupScanProgress =>
(event as WorkspaceCleanupScanProgress).candidateMode === 'append' &&
(event as WorkspaceCleanupScanProgress).candidates.length > 0
)
expect(candidateProgress).toHaveLength(2)
expect(candidateProgress.every((event) => event.candidates.length === 1)).toBe(true)
const progressWorktreeIds = candidateProgress.flatMap((event) =>
event.candidates.map((candidate) => candidate.worktreeId)
)
expect(progressWorktreeIds).toEqual(
expect.arrayContaining(['repo-1::/repo-feature-a', 'repo-1::/repo-feature-b'])
)
expect(progress.at(-1)).toMatchObject({
scanId: 'scan-1',
scannedWorktreeCount: 2,
totalWorktreeCount: 2
})
})
it('keeps raw scan errors out of renderer-facing results', async () => {
listRepoWorktreesMock.mockRejectedValue(new Error('fatal: path /Users/alice/private failed'))
@@ -185,6 +238,30 @@ describe('workspace cleanup scan', () => {
])
})
it('aborts a hung worktree list when the cleanup timeout fires', async () => {
let signal: AbortSignal | undefined
listRepoWorktreesMock.mockImplementation((_repo: Repo, options?: { signal?: AbortSignal }) => {
signal = options?.signal
return new Promise<GitWorktreeInfo[]>(() => {})
})
const scan = scanWorkspaceCleanup(makeStore())
await vi.advanceTimersByTimeAsync(8_000)
await expect(scan).resolves.toEqual({
scannedAt: NOW,
candidates: [],
errors: [
{
repoId: 'repo-1',
repoName: 'Repo',
message: 'Timed out listing worktrees.'
}
]
})
expect(signal?.aborted).toBe(true)
})
it('skips disconnected remote workspaces without a scan warning', async () => {
const result = await scanWorkspaceCleanup(
makeStore({
@@ -263,8 +340,12 @@ describe('workspace cleanup scan', () => {
})
)
expect(provider.listWorktrees).toHaveBeenCalledWith('/repo')
expect(provider.getStatus).toHaveBeenCalledWith('/remote/repo-feature')
expect(provider.listWorktrees).toHaveBeenCalledWith('/repo', {
signal: expect.any(AbortSignal)
})
expect(provider.getStatus).toHaveBeenCalledWith('/remote/repo-feature', {
signal: expect.any(AbortSignal)
})
expect(result.errors).toEqual([])
expect(result.candidates[0]).toMatchObject({
connectionId: 'ssh-1',
@@ -287,7 +368,9 @@ describe('workspace cleanup scan', () => {
})
)
expect(provider.listWorktrees).toHaveBeenCalledWith('/repo')
expect(provider.listWorktrees).toHaveBeenCalledWith('/repo', {
signal: expect.any(AbortSignal)
})
expect(result.errors).toEqual([])
expect(result.candidates).toEqual([])
})
@@ -324,6 +407,52 @@ describe('workspace cleanup scan', () => {
})
})
it('only inspects the target repo during focused scans', async () => {
const repoTwo = { ...REPO, id: 'repo-2', path: '/repo-two', displayName: 'Repo Two' }
listRepoWorktreesMock.mockImplementation((repo: Repo) =>
Promise.resolve([
{
path: `${repo.path}-feature`,
head: 'abc123',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false
}
])
)
const result = await scanWorkspaceCleanup(
makeStore({
repos: [REPO, repoTwo]
}),
{ worktreeId: 'repo-2::/repo-two-feature' }
)
expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1)
expect(listRepoWorktreesMock).toHaveBeenCalledWith(repoTwo, {
signal: expect.any(AbortSignal)
})
expect(result.candidates).toHaveLength(1)
expect(result.candidates[0]).toMatchObject({
worktreeId: 'repo-2::/repo-two-feature',
repoId: 'repo-2'
})
})
it('returns no focused scan rows when the encoded repo id is unknown', async () => {
const result = await scanWorkspaceCleanup(makeStore(), {
worktreeId: 'missing-repo::/repo-feature'
})
expect(listRepoWorktreesMock).not.toHaveBeenCalled()
expect(getStatusMock).not.toHaveBeenCalled()
expect(result).toEqual({
scannedAt: NOW,
candidates: [],
errors: []
})
})
it('honors renderer git deferrals without hiding the workspace', async () => {
const result = await scanWorkspaceCleanup(makeStore(), {
skipGitWorktreeIds: ['repo-1::/repo-feature']
@@ -353,7 +482,7 @@ describe('workspace cleanup scan', () => {
expect(getStatusMock).toHaveBeenCalledTimes(1)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['rev-list', '--count', 'HEAD', '--not', '--remotes'],
{ cwd: '/repo-feature' }
{ cwd: '/repo-feature', signal: expect.any(AbortSignal) }
)
expect(result.candidates[0]).toMatchObject({
tier: 'ready',
+9 -591
View File
@@ -1,52 +1,20 @@
/* eslint-disable max-lines */
import { ipcMain } from 'electron'
import { basename } from 'node:path'
import type { Store } from '../persistence'
import { getStatus } from '../git/status'
import { gitExecFileAsync } from '../git/runner'
import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import type { IGitProvider, IPtyProvider } from '../providers/types'
import type { IPtyProvider } from '../providers/types'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { listRegisteredPtys } from '../memory/pty-registry'
import { getSshPtyProvider } from './pty'
import { isFolderRepo } from '../../shared/repo-kind'
import type {
GitStatusResult,
GitWorktreeInfo,
Repo,
Worktree,
WorktreeMeta
} from '../../shared/types'
import { mergeWorktree } from './worktree-logic'
import { splitWorktreeId } from '../../shared/worktree-id'
import {
WORKSPACE_CLEANUP_CLASSIFIER_VERSION,
applyWorkspaceCleanupPolicy,
createWorkspaceCleanupFingerprint,
getWorkspaceCleanupInactivityReasons,
isWorkspaceOldForCleanup,
type WorkspaceCleanupBlocker,
type WorkspaceCleanupCandidate,
type WorkspaceCleanupDismissArgs,
type WorkspaceCleanupLocalProcessArgs,
type WorkspaceCleanupLocalProcessResult,
type WorkspaceCleanupReason,
type WorkspaceCleanupScanError,
type WorkspaceCleanupScanArgs,
type WorkspaceCleanupScanResult
} from '../../shared/workspace-cleanup'
import { scanWorkspaceCleanup } from './workspace-cleanup-scan'
const GIT_READ_TIMEOUT_MS = 8_000
const WORKTREE_SCAN_CONCURRENCY = 3
type GitEvidence = {
clean: boolean | null
upstreamAhead: number | null
upstreamBehind: number | null
checkedAt: number | null
blockers: WorkspaceCleanupBlocker[]
}
export { scanWorkspaceCleanup }
type WorkspaceCleanupHandlerDeps = {
runtime?: OrcaRuntimeService
@@ -64,8 +32,12 @@ export function registerWorkspaceCleanupHandlers(
ipcMain.handle(
'workspaceCleanup:scan',
(_event, args?: WorkspaceCleanupScanArgs): Promise<WorkspaceCleanupScanResult> =>
scanWorkspaceCleanup(store, args ?? {})
(event, args?: WorkspaceCleanupScanArgs): Promise<WorkspaceCleanupScanResult> =>
scanWorkspaceCleanup(store, args ?? {}, {
onProgress: args?.scanId
? (progress) => event.sender.send('workspaceCleanup:scanProgress', progress)
: undefined
})
)
ipcMain.handle('workspaceCleanup:dismiss', (_event, args: WorkspaceCleanupDismissArgs) => {
@@ -187,557 +159,3 @@ function normalizeRemotePath(path: string): string {
function isPathWithin(candidatePath: string, parentPath: string): boolean {
return candidatePath === parentPath || candidatePath.startsWith(`${parentPath}/`)
}
export async function scanWorkspaceCleanup(
store: Store,
args: WorkspaceCleanupScanArgs = {}
): Promise<WorkspaceCleanupScanResult> {
const scannedAt = Date.now()
const repos = store.getRepos()
const errors: WorkspaceCleanupScanResult['errors'] = []
const candidates: WorkspaceCleanupCandidate[] = []
for (const repo of repos) {
const result = await scanRepoWorkspaces({
store,
repo,
scannedAt,
targetWorktreeId: args.worktreeId,
skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? [])
})
appendListItems(candidates, result.candidates)
appendListItems(errors, result.errors)
}
return { scannedAt, candidates, errors }
}
async function scanRepoWorkspaces(args: {
store: Store
repo: Repo
scannedAt: number
targetWorktreeId?: string
skipGitWorktreeIds: Set<string>
}): Promise<WorkspaceCleanupScanResult> {
const { store, repo, scannedAt, targetWorktreeId, skipGitWorktreeIds } = args
const errors: WorkspaceCleanupScanResult['errors'] = []
let provider: IGitProvider | null = null
let gitWorktrees: GitWorktreeInfo[] = []
const repoIsFolder = isFolderRepo(repo)
try {
if (repoIsFolder) {
gitWorktrees = [createFolderWorktree(repo)]
} else if (repo.connectionId) {
provider = getSshGitProvider(repo.connectionId) ?? null
if (!provider) {
// Why: cleanup should reflect only workspaces Orca can currently
// inspect. Disconnected SSH repos are skipped in broad scans.
return {
scannedAt,
candidates: targetWorktreeId
? synthesizeDisconnectedSshCandidates(store, repo, scannedAt, targetWorktreeId)
: [],
errors: []
}
}
gitWorktrees = await withTimeout(
provider.listWorktrees(repo.path),
GIT_READ_TIMEOUT_MS,
'Timed out listing SSH worktrees.'
)
} else {
gitWorktrees = await withTimeout(
listRepoWorktrees(repo),
GIT_READ_TIMEOUT_MS,
'Timed out listing worktrees.'
)
}
} catch (error) {
console.error('Workspace cleanup repo scan failed', error)
if (repo.connectionId && !targetWorktreeId) {
// Why: broad cleanup only shows remote workspaces Orca can inspect now.
// A connected SSH repo that fails mid-scan is omitted, not bannered.
return { scannedAt, candidates: [], errors: [] }
}
errors.push(createScanError(repo, toSafeRepoScanError(error)))
return { scannedAt, candidates: [], errors }
}
const worktrees = gitWorktrees
.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
const meta = store.getWorktreeMeta(worktreeId)
return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
})
.filter((worktree) => {
if (targetWorktreeId) {
return worktree.id === targetWorktreeId
}
return (
!repoIsFolder &&
!worktree.isMainWorktree &&
isWorkspaceInactiveForCleanup(worktree, scannedAt)
)
})
const candidates = await mapWithConcurrency(worktrees, WORKTREE_SCAN_CONCURRENCY, (worktree) =>
buildCandidate({
repo,
worktree,
scannedAt,
provider,
skipGit: skipGitWorktreeIds.has(worktree.id),
forceGitCheck: Boolean(targetWorktreeId)
}).catch((error) => {
console.error('Workspace cleanup candidate scan failed', error)
return buildCandidateFromError(repo, worktree, scannedAt, toErrorMessage(error))
})
)
return { scannedAt, candidates, errors }
}
async function buildCandidate(args: {
repo: Repo
worktree: Worktree
scannedAt: number
provider: IGitProvider | null
skipGit: boolean
forceGitCheck: boolean
}): Promise<WorkspaceCleanupCandidate> {
const { repo, worktree, scannedAt, provider, skipGit, forceGitCheck } = args
const blockers: WorkspaceCleanupBlocker[] = []
const reasons = getInactivityReasons(worktree, scannedAt)
const repoIsFolder = isFolderRepo(repo)
if (worktree.isMainWorktree) {
blockers.push('main-worktree')
}
if (repoIsFolder) {
blockers.push('folder-repo')
}
if (worktree.isPinned) {
blockers.push('pinned')
}
const localContext = buildLocalContext(worktree)
const shouldReadGit = shouldReadGitEvidence({
repoIsFolder,
blockers,
worktree,
skipGit,
forceGitCheck
})
const gitEvidence = !shouldReadGit
? createEmptyGitEvidence()
: await readGitEvidence(worktree, repo, provider)
appendListItems(blockers, gitEvidence.blockers)
const candidateWithoutFingerprint: WorkspaceCleanupCandidate = {
worktreeId: worktree.id,
repoId: repo.id,
repoName: repo.displayName,
connectionId: repo.connectionId ?? null,
displayName: worktree.displayName,
branch: shortBranchName(worktree.branch),
path: worktree.path,
tier: 'review',
selectedByDefault: false,
reasons,
blockers: uniqueBlockers(blockers),
lastActivityAt: worktree.lastActivityAt,
...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}),
localContext,
git: {
clean: gitEvidence.clean,
upstreamAhead: gitEvidence.upstreamAhead,
upstreamBehind: gitEvidence.upstreamBehind,
checkedAt: gitEvidence.checkedAt
},
fingerprint: ''
}
const fingerprint = createWorkspaceCleanupFingerprint({
branch: candidateWithoutFingerprint.branch,
head: worktree.head,
gitClean: gitEvidence.clean,
lastActivityAt: worktree.lastActivityAt
})
return applyWorkspaceCleanupPolicy({
...candidateWithoutFingerprint,
reasons: uniqueReasons(reasons),
blockers: uniqueBlockers(blockers),
fingerprint
})
}
function shouldReadGitEvidence(args: {
repoIsFolder: boolean
blockers: WorkspaceCleanupBlocker[]
worktree: Worktree
skipGit: boolean
forceGitCheck: boolean
}): boolean {
const { repoIsFolder, blockers, worktree, skipGit, forceGitCheck } = args
if ((skipGit && !forceGitCheck) || repoIsFolder || worktree.isMainWorktree) {
return false
}
if (
blockers.includes('pinned') ||
blockers.includes('main-worktree') ||
blockers.includes('folder-repo')
) {
return false
}
// Why: inactivity is the only recommendation signal now. Git is read only
// to keep the destructive path from deleting dirty or local-only branch work.
return true
}
function appendListItems<T>(target: T[], entries: readonly T[]): void {
// Why: cleanup can aggregate generated-size worktree batches; spreading
// those batches into push can exceed JavaScript's argument limit.
for (const entry of entries) {
target.push(entry)
}
}
async function readGitEvidence(
worktree: Worktree,
repo: Repo,
provider: IGitProvider | null
): Promise<GitEvidence> {
const blockers: WorkspaceCleanupBlocker[] = []
let status: GitStatusResult
const checkedAt = Date.now()
try {
status = await withTimeout(
repo.connectionId ? provider!.getStatus(worktree.path) : getStatus(worktree.path),
GIT_READ_TIMEOUT_MS,
'Timed out reading git status.'
)
} catch {
return {
...createEmptyGitEvidence(),
blockers: ['git-status-error']
}
}
if (status.upstreamStatus === undefined) {
return {
...createEmptyGitEvidence(),
blockers: ['git-status-error']
}
}
const clean = status.entries.length === 0
if (!clean) {
blockers.push('dirty-files')
}
const upstreamAhead = status.upstreamStatus.hasUpstream ? status.upstreamStatus.ahead : null
const upstreamBehind = status.upstreamStatus.hasUpstream ? status.upstreamStatus.behind : null
if (upstreamAhead !== null && upstreamAhead > 0) {
blockers.push('unpushed-commits')
}
if (clean && upstreamAhead === null) {
const unpushedCommitCount = await readUnpushedCommitCount(worktree, repo, provider)
if (unpushedCommitCount === null) {
blockers.push('unknown-base')
} else if (unpushedCommitCount > 0) {
blockers.push('unpushed-commits')
}
}
const evidence: GitEvidence = {
clean,
upstreamAhead,
upstreamBehind,
checkedAt,
blockers
}
return { ...evidence, blockers: uniqueBlockers(blockers) }
}
async function readUnpushedCommitCount(
worktree: Worktree,
repo: Repo,
provider: IGitProvider | null
): Promise<number | null> {
try {
const result = await withTimeout(
repo.connectionId
? provider!.exec(['rev-list', '--count', 'HEAD', '--not', '--remotes'], worktree.path)
: gitExecFileAsync(['rev-list', '--count', 'HEAD', '--not', '--remotes'], {
cwd: worktree.path
}),
GIT_READ_TIMEOUT_MS,
'Timed out checking unpushed commits.'
)
const count = Number.parseInt(result.stdout.trim(), 10)
return Number.isFinite(count) ? count : null
} catch {
return null
}
}
function buildCandidateFromError(
repo: Repo,
worktree: Worktree,
scannedAt: number,
_message: string
): WorkspaceCleanupCandidate {
return applyWorkspaceCleanupPolicy({
worktreeId: worktree.id,
repoId: repo.id,
repoName: repo.displayName,
connectionId: repo.connectionId ?? null,
displayName: worktree.displayName,
branch: shortBranchName(worktree.branch),
path: worktree.path,
tier: 'protected',
selectedByDefault: false,
reasons: getInactivityReasons(worktree, scannedAt),
blockers: ['git-status-error'],
lastActivityAt: worktree.lastActivityAt,
...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}),
localContext: buildLocalContext(worktree),
git: {
clean: null,
upstreamAhead: null,
upstreamBehind: null,
checkedAt: scannedAt
},
fingerprint: createWorkspaceCleanupFingerprint({
branch: shortBranchName(worktree.branch),
head: worktree.head,
gitClean: null,
lastActivityAt: worktree.lastActivityAt
})
})
}
function synthesizeDisconnectedSshCandidates(
store: Store,
repo: Repo,
scannedAt: number,
targetWorktreeId?: string
): WorkspaceCleanupCandidate[] {
const repoWorktreePrefix = `${repo.id}::`
if (targetWorktreeId) {
if (!targetWorktreeId.startsWith(repoWorktreePrefix)) {
return []
}
// Why: focused delete preflight names one workspace already; walking all
// persisted metadata is unnecessary for disconnected SSH repos.
const meta = store.getWorktreeMeta(targetWorktreeId)
return meta ? [createDisconnectedSshCandidate(repo, scannedAt, targetWorktreeId, meta)] : []
}
const candidates: WorkspaceCleanupCandidate[] = []
const allMeta = store.getAllWorktreeMeta()
for (const worktreeId in allMeta) {
if (!Object.hasOwn(allMeta, worktreeId) || !worktreeId.startsWith(repoWorktreePrefix)) {
continue
}
const meta = allMeta[worktreeId]
if (!meta || !isWorkspaceInactiveForCleanup(meta, scannedAt)) {
continue
}
candidates.push(createDisconnectedSshCandidate(repo, scannedAt, worktreeId, meta))
}
return candidates
}
function createDisconnectedSshCandidate(
repo: Repo,
scannedAt: number,
worktreeId: string,
meta: WorktreeMeta
): WorkspaceCleanupCandidate {
const parsed = splitWorktreeId(worktreeId)
const path = parsed?.worktreePath ?? worktreeId
const reasons = getInactivityReasons(meta, scannedAt)
return applyWorkspaceCleanupPolicy({
worktreeId,
repoId: repo.id,
repoName: repo.displayName,
connectionId: repo.connectionId ?? null,
displayName: meta.displayName || basename(path),
branch: basename(path),
path,
tier: 'protected',
selectedByDefault: false,
reasons,
blockers: ['ssh-disconnected'],
lastActivityAt: meta.lastActivityAt,
...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}),
localContext: {
terminalTabCount: 0,
cleanEditorTabCount: 0,
browserTabCount: 0,
diffCommentCount: meta.diffComments?.length ?? 0,
newestDiffCommentAt: getNewestDiffCommentAt(meta.diffComments),
retainedDoneAgentCount: 0
},
git: {
clean: null,
upstreamAhead: null,
upstreamBehind: null,
checkedAt: null
},
fingerprint: createWorkspaceCleanupFingerprint({
branch: basename(path),
head: '',
gitClean: null,
lastActivityAt: meta.lastActivityAt
})
})
}
function buildLocalContext(worktree: Worktree): WorkspaceCleanupCandidate['localContext'] {
return {
terminalTabCount: 0,
cleanEditorTabCount: 0,
browserTabCount: 0,
diffCommentCount: worktree.diffComments?.length ?? 0,
newestDiffCommentAt: getNewestDiffCommentAt(worktree.diffComments),
retainedDoneAgentCount: 0
}
}
function getNewestDiffCommentAt(diffComments: Worktree['diffComments'] | undefined): number | null {
if (!diffComments || diffComments.length === 0) {
return null
}
// Why: persisted diff notes can grow large enough for spread-based Math.max
// to exceed the JavaScript argument limit during cleanup scans.
let newest = diffComments[0]?.createdAt ?? null
for (let index = 1; index < diffComments.length; index += 1) {
const createdAt = diffComments[index]?.createdAt
if (createdAt !== undefined && (newest === null || createdAt > newest)) {
newest = createdAt
}
}
return newest
}
function createEmptyGitEvidence(): GitEvidence {
return {
clean: null,
upstreamAhead: null,
upstreamBehind: null,
checkedAt: null,
blockers: []
}
}
function shortBranchName(branch: string): string {
return branch.replace(/^refs\/heads\//, '') || 'HEAD'
}
function uniqueBlockers(blockers: WorkspaceCleanupBlocker[]): WorkspaceCleanupBlocker[] {
return [...new Set(blockers)]
}
function uniqueReasons(reasons: WorkspaceCleanupReason[]): WorkspaceCleanupReason[] {
return [...new Set(reasons)]
}
function isWorkspaceInactiveForCleanup(
workspace: Pick<Worktree, 'isArchived' | 'lastActivityAt'>,
scannedAt: number
): boolean {
return isWorkspaceOldForCleanup(workspace, scannedAt)
}
function getInactivityReasons(
workspace: Pick<Worktree, 'isArchived' | 'lastActivityAt'>,
scannedAt: number
): WorkspaceCleanupReason[] {
return getWorkspaceCleanupInactivityReasons(workspace, scannedAt)
}
async function mapWithConcurrency<T, R>(
items: readonly T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> {
const results: R[] = []
let nextIndex = 0
const workerCount = Math.min(limit, items.length)
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const index = nextIndex
nextIndex += 1
results[index] = await fn(items[index])
}
})
)
return results
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timeoutId: NodeJS.Timeout | undefined
const timeoutPromise = new Promise<T>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs)
})
try {
return await Promise.race([promise, timeoutPromise])
} finally {
if (timeoutId) {
clearTimeout(timeoutId)
}
}
}
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function createScanError(repo: Repo, message: string): WorkspaceCleanupScanError {
return {
repoId: repo.id,
repoName: repo.displayName || basename(repo.path),
message
}
}
// Why: git errors often include absolute paths or command output. Keep the
// cause useful without leaking raw local/remote filesystem details to the UI.
function toSafeRepoScanError(error: unknown): string {
const message = toErrorMessage(error)
if (message === 'Timed out listing SSH worktrees.') {
return 'Timed out listing remote worktrees.'
}
if (message === 'Timed out listing worktrees.') {
return 'Timed out listing worktrees.'
}
if (message.startsWith('Timed out ')) {
return message.replace(/\.$/, '')
}
const lower = message.toLowerCase()
if (lower.includes('not a git repository') || lower.includes('not a git worktree')) {
return 'Repository is not a git checkout.'
}
if (
lower.includes('enoent') ||
lower.includes('no such file') ||
lower.includes('cannot find') ||
lower.includes('does not exist')
) {
return 'Repository folder was not found.'
}
if (lower.includes('eacces') || lower.includes('eperm') || lower.includes('permission denied')) {
return 'Repository folder is not accessible.'
}
return 'Git could not list worktrees.'
}
@@ -0,0 +1,102 @@
import {
normalizeRuntimePathForComparison,
relativePathInsideRoot
} from '../../shared/cross-platform-path'
type WorktreeBaseWatcherEvent = {
type: 'create' | 'update' | 'delete'
path: string
}
export type WorktreeBaseWatchKind = 'base' | 'git-common'
export type WorktreeBaseRepoWatchConfig = {
repoId: string
repoName: string
nestWorkspaces: boolean
}
export type WorktreeBaseWatchTarget = {
key: string
kind: WorktreeBaseWatchKind
path: string
connectionId?: string
repos: Map<string, WorktreeBaseRepoWatchConfig>
}
export function pathRelativeToWorktreeWatchRoot(
rootPath: string,
candidatePath: string
): string[] | null {
const relativePath = relativePathInsideRoot(rootPath, candidatePath)
if (relativePath === null) {
return null
}
return relativePath.split(/[\\/]+/).filter(Boolean)
}
function isRootCompletionEvent(parts: string[], config: WorktreeBaseRepoWatchConfig): boolean {
if (config.nestWorkspaces) {
return (
parts.length === 2 &&
normalizeRuntimePathForComparison(parts[0]) ===
normalizeRuntimePathForComparison(config.repoName)
)
}
return parts.length === 1
}
// Why: root creation can arrive before Git finishes registration; the `.git`
// marker is the checkout-complete signal, while deeper file churn is ignored.
function isGitMarkerCompletionEvent(parts: string[], config: WorktreeBaseRepoWatchConfig): boolean {
if (config.nestWorkspaces) {
return (
parts.length === 3 &&
normalizeRuntimePathForComparison(parts[0]) ===
normalizeRuntimePathForComparison(config.repoName) &&
parts[2] === '.git'
)
}
return parts.length === 2 && parts[1] === '.git'
}
function matchingBaseRepoIds(
target: WorktreeBaseWatchTarget,
eventPath: string,
eventType: string
): string[] {
const repoIds: string[] = []
const parts = pathRelativeToWorktreeWatchRoot(target.path, eventPath)
if (!parts) {
return repoIds
}
for (const config of target.repos.values()) {
if (
isGitMarkerCompletionEvent(parts, config) ||
(eventType === 'delete' && isRootCompletionEvent(parts, config))
) {
repoIds.push(config.repoId)
}
}
return repoIds
}
// Why: Git records linked worktrees under the common dir's `worktrees`
// metadata, which is lower churn than watching checkout contents.
function matchingGitCommonRepoIds(target: WorktreeBaseWatchTarget, eventPath: string): string[] {
const parts = pathRelativeToWorktreeWatchRoot(target.path, eventPath)
if (!parts || parts[0] !== 'worktrees') {
return []
}
return [...target.repos.keys()]
}
export function matchingWorktreeBaseRepoIds(
target: WorktreeBaseWatchTarget,
event: WorktreeBaseWatcherEvent
): string[] {
return target.kind === 'git-common'
? matchingGitCommonRepoIds(target, event.path)
: matchingBaseRepoIds(target, event.path, event.type)
}
@@ -0,0 +1,202 @@
import { normalize } from 'path'
import { realpath, stat } from 'fs/promises'
import type { Stats } from 'fs'
import type { Store } from '../persistence'
import type { FileStat, IFilesystemProvider } from '../providers/types'
import type { GlobalSettings, Repo } from '../../shared/types'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { isFolderRepo } from '../../shared/repo-kind'
import {
isRuntimePathAbsolute,
isWindowsAbsolutePathLike,
getRuntimePathBasename,
normalizeRuntimePathForComparison,
resolveRuntimePath
} from '../../shared/cross-platform-path'
import { isWslUncPath } from '../../shared/wsl-paths'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import {
computeWorkspaceRoot,
getWorktreePathSettings,
hasRepoWorktreeBasePath
} from './worktree-logic'
import { resolveWorktreeCommonGitDirectory } from './worktree-common-git-directory'
import type {
WorktreeBaseRepoWatchConfig,
WorktreeBaseWatchKind,
WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
const missingRootWarnings = new Set<string>()
const skippedWslWarnings = new Set<string>()
function normalizeWatchKey(pathValue: string): string {
return normalizeRuntimePathForComparison(normalize(pathValue))
}
async function canonicalizeExistingPath(
pathValue: string,
connectionId: string | undefined
): Promise<string> {
if (connectionId) {
const provider = getSshFilesystemProvider(connectionId)
if (!provider) {
return normalize(pathValue)
}
try {
return await provider.realpath(pathValue)
} catch {
return normalize(pathValue)
}
}
try {
return await realpath(pathValue)
} catch {
return normalize(pathValue)
}
}
function isDirectoryStat(value: Stats | FileStat | undefined): boolean {
if (!value) {
return false
}
return 'type' in value ? value.type === 'directory' : value.isDirectory()
}
async function addTarget(
targets: Map<string, WorktreeBaseWatchTarget>,
kind: WorktreeBaseWatchKind,
pathValue: string,
config: WorktreeBaseRepoWatchConfig,
connectionId?: string
): Promise<void> {
const watchedPath = await canonicalizeExistingPath(pathValue, connectionId)
const key = `${kind}:${connectionId ?? 'local'}:${normalizeWatchKey(watchedPath)}`
const existing = targets.get(key)
if (existing) {
existing.repos.set(config.repoId, config)
return
}
targets.set(key, {
key,
kind,
path: watchedPath,
...(connectionId ? { connectionId } : {}),
repos: new Map([[config.repoId, config]])
})
}
function getRemoteProvider(connectionId: string | undefined): IFilesystemProvider | undefined {
return connectionId ? getSshFilesystemProvider(connectionId) : undefined
}
function isRuntimePathAbsoluteForRepo(repoPath: string, pathValue: string): boolean {
const pathFlavor =
isWindowsAbsolutePathLike(repoPath) || isWindowsAbsolutePathLike(pathValue)
? 'windows'
: 'posix'
return isRuntimePathAbsolute(pathValue, pathFlavor)
}
function getBaseWatchLayout(
repo: Repo,
pathSettings: Pick<GlobalSettings, 'workspaceDir' | 'nestWorkspaces'>,
connectionId: string | undefined
): { workspaceRoot: string; nestWorkspaces: boolean } {
if (
connectionId &&
!hasRepoWorktreeBasePath(repo) &&
isRuntimePathAbsoluteForRepo(repo.path, pathSettings.workspaceDir)
) {
// Why: SSH creates default worktrees beside the remote repo when the
// global workspace dir is a desktop-local absolute path.
return { workspaceRoot: resolveRuntimePath(repo.path, '..'), nestWorkspaces: false }
}
return {
workspaceRoot: computeWorkspaceRoot(repo.path, pathSettings),
nestWorkspaces: pathSettings.nestWorkspaces
}
}
async function maybeAddBaseTarget(
targets: Map<string, WorktreeBaseWatchTarget>,
repo: Repo,
settings: GlobalSettings,
connectionId?: string
): Promise<void> {
const pathSettings = getWorktreePathSettings(repo, settings)
const { workspaceRoot, nestWorkspaces } = getBaseWatchLayout(repo, pathSettings, connectionId)
// Why: WSL UNC roots are unreliable for native watching; avoid project-level polling.
if (isWslUncPath(workspaceRoot) || isWslUncPath(repo.path)) {
const key = `${repo.id}:${workspaceRoot}`
if (!skippedWslWarnings.has(key)) {
skippedWslWarnings.add(key)
console.warn(
`[worktree-base-watcher] skipping WSL worktree root watcher for ${workspaceRoot}`
)
}
return
}
const config = {
repoId: repo.id,
repoName: getRuntimePathBasename(repo.path).replace(/\.git$/, ''),
nestWorkspaces
}
const remoteProvider = getRemoteProvider(connectionId)
if (connectionId && !remoteProvider) {
return
}
try {
const rootStat = remoteProvider
? await remoteProvider.stat(workspaceRoot)
: await stat(workspaceRoot)
if (isDirectoryStat(rootStat)) {
await addTarget(targets, 'base', workspaceRoot, config, connectionId)
}
} catch {
const key = normalizeWatchKey(workspaceRoot)
if (!missingRootWarnings.has(key)) {
missingRootWarnings.add(key)
console.warn(`[worktree-base-watcher] worktree root unavailable: ${workspaceRoot}`)
}
}
const commonDir = await resolveWorktreeCommonGitDirectory(
repo,
remoteProvider
? {
stat: (path) => remoteProvider.stat(path),
readFile: async (path) => (await remoteProvider.readFile(path)).content
}
: undefined
)
if (commonDir) {
await addTarget(targets, 'git-common', commonDir, config, connectionId)
}
}
export async function buildWorktreeBaseDirectoryWatchTargets(
store: Store
): Promise<Map<string, WorktreeBaseWatchTarget>> {
const settings = store.getSettings()
const targets = new Map<string, WorktreeBaseWatchTarget>()
for (const repo of store.getRepos()) {
if (isFolderRepo(repo)) {
continue
}
const executionHostId = getRepoExecutionHostId(repo)
if (executionHostId === LOCAL_EXECUTION_HOST_ID) {
await maybeAddBaseTarget(targets, repo, settings)
} else if (repo.connectionId) {
await maybeAddBaseTarget(targets, repo, settings, repo.connectionId)
}
}
return targets
}
export function clearWorktreeBaseDirectoryWatchTargetWarnings(): void {
missingRootWarnings.clear()
skippedWslWarnings.clear()
}
@@ -0,0 +1,255 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { join, sep } from 'path'
import type { Event as WatcherEvent, SubscribeCallback } from '@parcel/watcher'
import type { GlobalSettings, Repo } from '../../shared/types'
vi.mock('fs/promises', () => ({
readFile: vi.fn(async () => ''),
realpath: vi.fn(async (path: string) => path),
stat: vi.fn(async () => ({ isDirectory: () => true }))
}))
vi.mock('@parcel/watcher', () => ({
subscribe: vi.fn()
}))
vi.mock('./worktree-remote', () => ({
notifyWorktreesChanged: vi.fn()
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn()
}))
import { subscribe } from '@parcel/watcher'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { notifyWorktreesChanged } from './worktree-remote'
import {
disposeWorktreeBaseDirectoryWatchers,
syncWorktreeBaseDirectoryWatchers
} from './worktree-base-directory-watcher'
import { matchingWorktreeBaseRepoIds } from './worktree-base-directory-event-filter'
type WatcherCallback = SubscribeCallback
const watcherCallbacks = new Map<string, WatcherCallback>()
const unsubscribeMocks = new Map<string, ReturnType<typeof vi.fn>>()
const absolutePath = (...parts: string[]): string => join(sep, ...parts)
const WORKTREE_ROOT = absolutePath('workspace', 'worktrees')
const PROJECT_ROOT = absolutePath('workspace', 'projects', 'project')
const PROJECT_GIT_COMMON_DIR = join(PROJECT_ROOT, '.git')
const settings = {
workspaceDir: WORKTREE_ROOT,
nestWorkspaces: true
} as GlobalSettings
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-1',
path: PROJECT_ROOT,
displayName: 'Project',
badgeColor: '#000000',
addedAt: 1,
...overrides
} as Repo
}
function makeStore(repos: Repo[]) {
return {
getSettings: () => settings,
getRepos: () => repos
}
}
function makeWindow(options: { destroyed?: () => boolean } = {}) {
return {
isDestroyed: () => options.destroyed?.() ?? false,
webContents: { send: vi.fn() }
}
}
function emit(root: string, events: WatcherEvent[]): void {
const callback = watcherCallbacks.get(root)
if (!callback) {
throw new Error(`No watcher callback for ${root}`)
}
callback(null, events)
}
describe('worktree base directory watcher', () => {
beforeEach(() => {
vi.useFakeTimers()
watcherCallbacks.clear()
unsubscribeMocks.clear()
vi.mocked(getSshFilesystemProvider).mockReturnValue(undefined)
vi.mocked(subscribe).mockImplementation(async (root, callback) => {
const unsubscribe = vi.fn(async () => {})
watcherCallbacks.set(root, callback)
unsubscribeMocks.set(root, unsubscribe)
return { unsubscribe }
})
})
afterEach(async () => {
await disposeWorktreeBaseDirectoryWatchers()
vi.useRealTimers()
vi.clearAllMocks()
})
it('coalesces nested worktree completion events into one targeted notification', async () => {
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
emit(WORKTREE_ROOT, [
{ type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104') },
{ type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') }
] as WatcherEvent[])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).toHaveBeenCalledTimes(1)
expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1')
})
it('drops pending worktree notifications after the window is destroyed', async () => {
let destroyed = false
await syncWorktreeBaseDirectoryWatchers(
makeStore([makeRepo()]) as never,
makeWindow({ destroyed: () => destroyed }) as never
)
emit(WORKTREE_ROOT, [
{ type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') }
] as WatcherEvent[])
destroyed = true
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).not.toHaveBeenCalled()
})
it('ignores deep checkout churn below candidate roots', async () => {
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
emit(WORKTREE_ROOT, [
{ type: 'update', path: join(WORKTREE_ROOT, 'project', 'existing', 'src', 'file.ts') }
] as WatcherEvent[])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).not.toHaveBeenCalled()
})
it('uses Git common-dir worktree metadata as a low-churn completion signal', async () => {
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
emit(PROJECT_GIT_COMMON_DIR, [
{ type: 'create', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'gitdir') }
] as WatcherEvent[])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1')
})
it('does not install local desktop watchers for runtime or folder repos', async () => {
await syncWorktreeBaseDirectoryWatchers(
makeStore([
makeRepo({ id: 'runtime', executionHostId: 'runtime:dev' }),
makeRepo({ id: 'folder', kind: 'folder' })
]) as never,
makeWindow() as never
)
expect(subscribe).not.toHaveBeenCalled()
})
it('uses the remote sibling root for default SSH worktree roots', async () => {
const remoteCallbacks = new Map<string, (events: never[]) => void>()
const remoteUnwatch = vi.fn()
const remoteWatch = vi.fn(async (root: string, callback: (events: never[]) => void) => {
remoteCallbacks.set(root, callback)
return remoteUnwatch
})
vi.mocked(getSshFilesystemProvider).mockReturnValue({
stat: vi.fn(async () => ({ type: 'directory', size: 0, mtime: 0 })),
realpath: vi.fn(async (path: string) => path),
readFile: vi.fn(async () => ({ content: '', isBinary: false })),
watch: remoteWatch
} as never)
await syncWorktreeBaseDirectoryWatchers(
makeStore([makeRepo({ connectionId: 'ssh-1', path: '/home/alice/project' })]) as never,
makeWindow() as never
)
expect(subscribe).not.toHaveBeenCalled()
expect(remoteWatch).toHaveBeenCalledWith('/home/alice', expect.any(Function))
remoteCallbacks.get('/home/alice')?.([
{
kind: 'create',
absolutePath: '/home/alice/external-5104/.git'
}
] as never[])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1')
await disposeWorktreeBaseDirectoryWatchers()
expect(remoteUnwatch).toHaveBeenCalled()
})
it('unsubscribes roots that disappear after repo settings change', async () => {
const repo = makeRepo()
const store = makeStore([repo])
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never)
const otherRoot = absolutePath('workspace', 'other-worktrees')
repo.worktreeBasePath = otherRoot
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never)
expect(unsubscribeMocks.get(WORKTREE_ROOT)).toHaveBeenCalled()
expect(watcherCallbacks.has(otherRoot)).toBe(true)
})
it('unsubscribes a watcher that finishes installing after disposal starts', async () => {
let resolveSubscribe: (subscription: { unsubscribe: () => Promise<void> }) => void = () => {}
const unsubscribe = vi.fn(async () => {})
vi.mocked(subscribe).mockImplementationOnce(
async () =>
new Promise((resolve) => {
resolveSubscribe = resolve
})
)
const syncPromise = syncWorktreeBaseDirectoryWatchers(
makeStore([makeRepo()]) as never,
makeWindow() as never
)
await vi.waitFor(() => expect(subscribe).toHaveBeenCalled())
const disposePromise = disposeWorktreeBaseDirectoryWatchers()
resolveSubscribe({ unsubscribe })
await syncPromise
await disposePromise
expect(unsubscribe).toHaveBeenCalled()
})
it('matches flat workspace .git marker events without matching sibling churn', () => {
const target = {
key: `base:${WORKTREE_ROOT}`,
kind: 'base' as const,
path: WORKTREE_ROOT,
repos: new Map([['repo-1', { repoId: 'repo-1', repoName: 'project', nestWorkspaces: false }]])
}
expect(
matchingWorktreeBaseRepoIds(target, {
type: 'create',
path: join(WORKTREE_ROOT, 'external-5104', '.git')
} as WatcherEvent)
).toEqual(['repo-1'])
expect(
matchingWorktreeBaseRepoIds(target, {
type: 'update',
path: join(WORKTREE_ROOT, 'external-5104', 'src', 'file.ts')
} as WatcherEvent)
).toEqual([])
})
})
@@ -0,0 +1,286 @@
import type { BrowserWindow } from 'electron'
import type { AsyncSubscription } from '@parcel/watcher'
import type { FsChangeEvent } from '../../shared/types'
import type { Store } from '../persistence'
import { notifyWorktreesChanged } from './worktree-remote'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import {
matchingWorktreeBaseRepoIds,
type WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
import {
buildWorktreeBaseDirectoryWatchTargets,
clearWorktreeBaseDirectoryWatchTargetWarnings
} from './worktree-base-directory-watch-targets'
type ActiveWatch = WorktreeBaseWatchTarget & {
mainWindow: BrowserWindow
subscription: Pick<AsyncSubscription, 'unsubscribe'>
notifyTimer: ReturnType<typeof setTimeout> | null
pendingRepoIds: Set<string>
disposed: boolean
}
const WATCH_DEBOUNCE_MS = 250
const activeWatches = new Map<string, ActiveWatch>()
let syncGeneration = 0
let scheduledSync: ReturnType<typeof setTimeout> | null = null
let latestSyncContext: { mainWindow: BrowserWindow; store: Store } | null = null
function scheduleNotification(watch: ActiveWatch, repoIds: readonly string[]): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
watch.pendingRepoIds.clear()
return
}
for (const repoId of repoIds) {
watch.pendingRepoIds.add(repoId)
}
if (watch.notifyTimer) {
clearTimeout(watch.notifyTimer)
}
watch.notifyTimer = setTimeout(() => {
watch.notifyTimer = null
if (watch.disposed || watch.mainWindow.isDestroyed()) {
watch.pendingRepoIds.clear()
return
}
const pending = [...watch.pendingRepoIds]
watch.pendingRepoIds.clear()
for (const repoId of pending) {
notifyWorktreesChanged(watch.mainWindow, repoId)
}
}, WATCH_DEBOUNCE_MS)
}
function collectMatchingRepoIds(
watch: ActiveWatch,
eventType: 'create' | 'update' | 'delete',
eventPath: string,
repoIds: Set<string>
): void {
for (const repoId of matchingWorktreeBaseRepoIds(watch, { type: eventType, path: eventPath })) {
repoIds.add(repoId)
}
}
function handleLocalWatchEvents(
watch: ActiveWatch,
error: Error | null,
events: { type: 'create' | 'update' | 'delete'; path: string }[]
): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
return
}
if (error) {
console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error)
scheduleNotification(watch, [...watch.repos.keys()])
return
}
const repoIds = new Set<string>()
for (const event of events) {
collectMatchingRepoIds(watch, event.type, event.path, repoIds)
}
if (repoIds.size > 0) {
scheduleNotification(watch, [...repoIds])
}
}
function handleRemoteWatchEvents(watch: ActiveWatch, events: FsChangeEvent[]): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
return
}
const repoIds = new Set<string>()
for (const event of events) {
if (event.kind === 'overflow') {
scheduleNotification(watch, [...watch.repos.keys()])
return
}
if (event.kind === 'rename') {
if (event.oldAbsolutePath) {
collectMatchingRepoIds(watch, 'delete', event.oldAbsolutePath, repoIds)
}
collectMatchingRepoIds(watch, 'create', event.absolutePath, repoIds)
continue
}
collectMatchingRepoIds(watch, event.kind, event.absolutePath, repoIds)
}
if (repoIds.size > 0) {
scheduleNotification(watch, [...repoIds])
}
}
async function subscribeTarget(
target: WorktreeBaseWatchTarget,
mainWindow: BrowserWindow
): Promise<ActiveWatch> {
let activeWatch: ActiveWatch | null = null
if (target.connectionId) {
const provider = getSshFilesystemProvider(target.connectionId)
if (!provider) {
throw new Error(`SSH filesystem provider unavailable for ${target.connectionId}`)
}
const unwatch = await provider.watch(target.path, (events) => {
const currentWatch = activeWatches.get(target.key) ?? activeWatch
if (!currentWatch || currentWatch.disposed) {
return
}
handleRemoteWatchEvents(currentWatch, events)
})
activeWatch = {
...target,
mainWindow,
subscription: { unsubscribe: async () => unwatch() },
notifyTimer: null,
pendingRepoIds: new Set(),
disposed: false
}
return activeWatch
}
const watcher = await import('@parcel/watcher')
const subscription = await watcher.subscribe(
target.path,
(error, events) => {
const currentWatch = activeWatches.get(target.key) ?? activeWatch
if (!currentWatch || currentWatch.disposed) {
return
}
handleLocalWatchEvents(currentWatch, error, events)
},
{
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**', '**/.cache/**'],
...(process.platform === 'win32' ? { backend: 'windows' as const } : {})
}
)
activeWatch = {
...target,
mainWindow,
subscription,
notifyTimer: null,
pendingRepoIds: new Set(),
disposed: false
}
return activeWatch
}
async function replaceWatch(
target: WorktreeBaseWatchTarget,
mainWindow: BrowserWindow,
generation: number
): Promise<void> {
const previous = activeWatches.get(target.key)
if (previous) {
previous.repos = target.repos
previous.mainWindow = mainWindow
return
}
try {
const activeWatch = await subscribeTarget(target, mainWindow)
if (generation !== syncGeneration) {
activeWatch.disposed = true
await activeWatch.subscription.unsubscribe().catch((error) => {
console.warn(`[worktree-base-watcher] failed to unwatch stale ${target.path}:`, error)
})
return
}
activeWatches.set(target.key, activeWatch)
} catch (error) {
console.warn(`[worktree-base-watcher] failed to watch ${target.path}:`, error)
}
}
async function removeWatch(key: string): Promise<void> {
const watch = activeWatches.get(key)
if (!watch) {
return
}
activeWatches.delete(key)
watch.disposed = true
if (watch.notifyTimer) {
clearTimeout(watch.notifyTimer)
}
await watch.subscription.unsubscribe().catch((error) => {
console.warn(`[worktree-base-watcher] failed to unwatch ${watch.path}:`, error)
})
}
export async function syncWorktreeBaseDirectoryWatchers(
store: Store,
mainWindow: BrowserWindow
): Promise<void> {
const generation = ++syncGeneration
const targets = await buildWorktreeBaseDirectoryWatchTargets(store)
if (generation !== syncGeneration) {
return
}
for (const key of activeWatches.keys()) {
if (generation !== syncGeneration) {
return
}
if (!targets.has(key)) {
await removeWatch(key)
if (generation !== syncGeneration) {
return
}
}
}
for (const target of targets.values()) {
if (generation !== syncGeneration) {
return
}
await replaceWatch(target, mainWindow, generation)
if (generation !== syncGeneration) {
return
}
}
}
export function setWorktreeBaseDirectoryWatcherSyncContext(
store: Store,
mainWindow: BrowserWindow
): void {
latestSyncContext = { store, mainWindow }
// Why: older integration tests use lean BrowserWindow stubs; real windows still
// clear this context on close so stale watcher syncs cannot target dead chrome.
if (typeof mainWindow.once === 'function') {
mainWindow.once('closed', () => {
if (latestSyncContext?.mainWindow === mainWindow) {
latestSyncContext = null
}
})
}
}
export function scheduleWorktreeBaseDirectoryWatcherSync(
store: Store,
mainWindow: BrowserWindow
): void {
if (scheduledSync) {
clearTimeout(scheduledSync)
}
scheduledSync = setTimeout(() => {
scheduledSync = null
if (mainWindow.isDestroyed()) {
return
}
void syncWorktreeBaseDirectoryWatchers(store, mainWindow)
}, 100)
}
export function scheduleCurrentWorktreeBaseDirectoryWatcherSync(): void {
if (!latestSyncContext || latestSyncContext.mainWindow.isDestroyed()) {
return
}
scheduleWorktreeBaseDirectoryWatcherSync(latestSyncContext.store, latestSyncContext.mainWindow)
}
export async function disposeWorktreeBaseDirectoryWatchers(): Promise<void> {
syncGeneration++
latestSyncContext = null
if (scheduledSync) {
clearTimeout(scheduledSync)
scheduledSync = null
}
await Promise.all([...activeWatches.keys()].map((key) => removeWatch(key)))
clearWorktreeBaseDirectoryWatchTargetWarnings()
}
@@ -0,0 +1,65 @@
import { readFile, stat } from 'fs/promises'
import type { Repo } from '../../shared/types'
import {
getRuntimePathBasename,
normalizeRuntimePathSeparators,
resolveRuntimePath
} from '../../shared/cross-platform-path'
import type { FileStat } from '../providers/types'
type GitDirectoryStat = Awaited<ReturnType<typeof stat>> | FileStat
type GitDirectoryAccess = {
stat?: (path: string) => Promise<GitDirectoryStat>
readFile?: (path: string) => Promise<string>
}
function isDirectoryStat(value: GitDirectoryStat): boolean {
return 'type' in value ? value.type === 'directory' : value.isDirectory()
}
function isFileStat(value: GitDirectoryStat): boolean {
return 'type' in value ? value.type === 'file' : value.isFile()
}
function runtimeDirname(pathValue: string): string {
const normalized = normalizeRuntimePathSeparators(pathValue).replace(/\/+$/, '')
const index = normalized.lastIndexOf('/')
if (index < 0) {
return '.'
}
if (index === 0) {
return '/'
}
return normalized.slice(0, index)
}
export async function resolveWorktreeCommonGitDirectory(
repo: Repo,
access: GitDirectoryAccess = {}
): Promise<string | null> {
const dotGitPath = resolveRuntimePath(repo.path, '.git')
const statPath = access.stat ?? stat
const readText = access.readFile ?? ((path: string) => readFile(path, 'utf8'))
try {
const dotGitStat = await statPath(dotGitPath)
if (isDirectoryStat(dotGitStat)) {
return dotGitPath
}
if (!isFileStat(dotGitStat)) {
return null
}
const content = await readText(dotGitPath)
const gitDir = content.match(/^gitdir:\s*(.+)\s*$/m)?.[1]?.trim()
if (!gitDir) {
return null
}
const resolvedGitDir = resolveRuntimePath(repo.path, gitDir)
return getRuntimePathBasename(runtimeDirname(resolvedGitDir)) === 'worktrees'
? runtimeDirname(runtimeDirname(resolvedGitDir))
: resolvedGitDir
} catch (error) {
console.warn(`[worktree-base-watcher] cannot resolve git common dir for ${repo.id}:`, error)
return null
}
}
+61 -12
View File
@@ -93,7 +93,12 @@ import { createWorktreeLinkedPaths } from './worktree-symlinks'
import { normalizeSparseDirectories } from './sparse-checkout-directories'
import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths'
import type { IFilesystemProvider } from '../providers/types'
import { buildSetupRunnerCommand } from '../../shared/setup-runner-command'
import {
buildSetupRunnerCommand,
getSetupRunnerCommandPlatformForPath
} from '../../shared/setup-runner-command'
import { createSequencedSetupAgentCommands } from '../../shared/setup-agent-sequencing'
import { shouldWaitForSetupBeforeAgentStartup } from '../../shared/setup-agent-startup-policy'
import { createWorktreeCreateTimingRecorder } from '../worktree-create-timing'
import {
markCodexProjectTrusted,
@@ -122,6 +127,7 @@ type RemoteWorktreeCreateBasePlan = {
type StagedStartupResult = {
startupTerminal?: CreateWorktreeResult['startupTerminal']
activationSetup?: CreateWorktreeResult['setup']
didSpawnSetup: boolean
warning?: string
}
@@ -230,6 +236,26 @@ async function spawnLocalStartupAndSetupTerminals(args: {
let startupTerminalHandle: string | null = null
let startupTerminal: CreateWorktreeResult['startupTerminal']
let sequencedStartup = startup
let wrappedSetupCommandStr: string | undefined
if (startup && setup?.waitForAgentStartup === true) {
const platform = getSetupRunnerCommandPlatformForPath(
setup.runnerScriptPath,
process.platform === 'win32' ? 'windows' : 'posix'
)
const sequenced = createSequencedSetupAgentCommands({
runnerScriptPath: setup.runnerScriptPath,
startupCommand: startup.command,
platform
})
sequencedStartup = {
...startup,
command: sequenced.startupCommand,
...(sequenced.startupEnv ? { env: { ...startup.env, ...sequenced.startupEnv } } : {})
}
wrappedSetupCommandStr = sequenced.setupCommand
}
try {
// Why: after `git worktree add` and metadata registration, a runtime-owned
// PTY can begin booting the selected agent while setup runs in a sibling
@@ -249,12 +275,13 @@ async function spawnLocalStartupAndSetupTerminals(args: {
}
}
const terminal = await runtime.createTerminal(`id:${worktree.id}`, {
command: startup.command,
env: startup.env,
...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}),
command: sequencedStartup.command,
...(setup ? { claudeAgentTeamsSourceCommand: startup.command } : {}),
env: sequencedStartup.env,
...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}),
...(isTuiAgent(createdWithAgent) ? { launchAgent: createdWithAgent } : {}),
startupCommandDelivery: startup.startupCommandDelivery,
telemetry: startup.telemetry,
startupCommandDelivery: sequencedStartup.startupCommandDelivery,
telemetry: sequencedStartup.telemetry,
activate: true
})
startupTerminalHandle = terminal.handle
@@ -272,10 +299,15 @@ async function spawnLocalStartupAndSetupTerminals(args: {
let didSpawnSetup = false
if (setup) {
try {
const setupCommand = buildSetupRunnerCommand(
setup.runnerScriptPath,
process.platform === 'win32' ? 'windows' : 'posix'
)
const setupCommand =
wrappedSetupCommandStr ??
buildSetupRunnerCommand(
setup.runnerScriptPath,
getSetupRunnerCommandPlatformForPath(
setup.runnerScriptPath,
process.platform === 'win32' ? 'windows' : 'posix'
)
)
const setupLaunchMode =
(settings as Partial<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
.setupScriptLaunchMode ?? 'new-tab'
@@ -307,6 +339,16 @@ async function spawnLocalStartupAndSetupTerminals(args: {
}
return {
...(setup && !didSpawnSetup
? {
activationSetup: {
...setup,
...(startupTerminalHandle && wrappedSetupCommandStr
? { command: wrappedSetupCommandStr }
: {})
}
}
: {}),
...(startupTerminal ? { startupTerminal } : {}),
didSpawnSetup,
...(warning ? { warning } : {})
@@ -1072,7 +1114,10 @@ async function createRemoteSetupRunnerScript(
)
return {
runnerScriptPath,
envVars: getSetupRunnerEnvVars(repo, worktreePath)
envVars: getSetupRunnerEnvVars(repo, worktreePath),
...(shouldWaitForSetupBeforeAgentStartup(repo.hookSettings?.setupAgentStartupPolicy)
? { waitForAgentStartup: true }
: {})
}
}
@@ -2390,7 +2435,11 @@ export async function createLocalWorktree(
return {
worktree: { ...worktree, workspaceLineage },
...(workspaceLineage ? { workspaceLineage } : {}),
...(setup && !stagedStartup.didSpawnSetup ? { setup } : {}),
...(stagedStartup.activationSetup
? { setup: stagedStartup.activationSetup }
: setup && !stagedStartup.didSpawnSetup
? { setup }
: {}),
...(defaultTabs ? { defaultTabs } : {}),
...(addResult.localBaseRefRefresh
? { localBaseRefRefresh: addResult.localBaseRefRefresh }
+62 -1
View File
@@ -940,6 +940,7 @@ describe('registerWorktreeHandlers', () => {
1,
'id:repo-1::/workspace/improve-dashboard',
{
claudeAgentTeamsSourceCommand: 'claude --prefill test',
command: 'claude --prefill test',
env: { ORCA_AGENT_MODE: 'direct' },
launchAgent: 'claude',
@@ -957,7 +958,7 @@ describe('registerWorktreeHandlers', () => {
'id:repo-1::/workspace/improve-dashboard',
{
title: 'Setup',
command: 'bash /workspace/repo/.git/orca/setup-runner.sh',
command: expect.stringContaining('bash /workspace/repo/.git/orca/setup-runner.sh'),
env: {
ORCA_ROOT_PATH: '/workspace/repo',
ORCA_WORKTREE_PATH: '/workspace/improve-dashboard'
@@ -965,6 +966,15 @@ describe('registerWorktreeHandlers', () => {
activate: false
}
)
const startupCreateCall = runtimeStub.createTerminal.mock.calls[0]
const setupCreateCall = runtimeStub.createTerminal.mock.calls[1]
if (!startupCreateCall || !setupCreateCall) {
throw new Error('expected startup and setup terminal calls')
}
const startupCommand = (startupCreateCall[1] as { command: string }).command
const setupCommand = (setupCreateCall[1] as { command: string }).command
expect(startupCommand).toBe('claude --prefill test')
expect(setupCommand).toBe('bash /workspace/repo/.git/orca/setup-runner.sh')
expect(result.setup).toBeUndefined()
expect(result.startupTerminal).toEqual({ spawned: true, surface: 'visible' })
expect(result.timing?.phases.map((phase) => phase.phase)).toEqual(
@@ -977,6 +987,57 @@ describe('registerWorktreeHandlers', () => {
)
})
it('returns the wrapped setup command when startup spawned but setup creation failed', async () => {
addWorktreeMock.mockResolvedValue({})
listWorktreesMock.mockResolvedValueOnce([
{
path: '/workspace/improve-dashboard',
head: 'def',
branch: 'improve-dashboard',
isBare: false,
isMainWorktree: false
}
])
loadHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
getEffectiveHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
shouldRunSetupForCreateMock.mockReturnValue(true)
createSetupRunnerScriptMock.mockReturnValueOnce({
runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh',
envVars: {
ORCA_ROOT_PATH: '/workspace/repo',
ORCA_WORKTREE_PATH: '/workspace/improve-dashboard'
},
waitForAgentStartup: true
})
runtimeStub.createTerminal
.mockResolvedValueOnce({ handle: 'term-startup', surface: 'visible' })
.mockRejectedValueOnce(new Error('setup creation failed'))
const result = (await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard',
createdWithAgent: 'claude',
startup: {
command: 'claude --prefill test',
env: { ORCA_AGENT_MODE: 'direct' },
telemetry: {
agent_kind: 'claude',
launch_source: 'new_workspace_composer',
request_kind: 'new'
}
}
})) as { setup?: { command?: string; runnerScriptPath: string } }
expect(result.setup).toEqual(
expect.objectContaining({
runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh',
command: expect.stringContaining('bash /workspace/repo/.git/orca/setup-runner.sh')
})
)
expect(result.setup?.command).toContain('printf')
})
it('checks out a selected existing local branch exactly', async () => {
listWorktreesMock
.mockResolvedValueOnce([
+14 -1
View File
@@ -3790,11 +3790,16 @@ export class Store {
| 'forkSyncMode'
| 'externalWorktreeVisibility'
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
| 'projectGroupId'
| 'projectGroupOrder'
| 'projectHostSetupMethod'
>
> & { sourceControlAi?: Repo['sourceControlAi'] | null }
> & {
sourceControlAi?: Repo['sourceControlAi'] | null
externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null
}
): Repo | null {
const repo = this.state.repos.find((r) => r.id === id)
if (!repo) {
@@ -3844,6 +3849,14 @@ export class Store {
// time visibility changes so later hide/show choices keep legacy safety.
repo.externalWorktreeVisibilityLegacy = externalWorktreeVisibilityLegacy
}
if (
'externalWorktreeDiscoverySuppressedAt' in sanitizedUpdates &&
(sanitizedUpdates.externalWorktreeDiscoverySuppressedAt === undefined ||
sanitizedUpdates.externalWorktreeDiscoverySuppressedAt === null)
) {
delete repo.externalWorktreeDiscoverySuppressedAt
delete sanitizedUpdates.externalWorktreeDiscoverySuppressedAt
}
if (
'sourceControlAi' in sanitizedUpdates &&
(sanitizedUpdates.sourceControlAi === undefined || sanitizedUpdates.sourceControlAi === null)
+5 -2
View File
@@ -103,11 +103,14 @@ export class SshGitProvider implements IGitProvider {
const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache
? { bypassEffectiveUpstreamNegativeCache: true }
: {}
return (await this.mux.request('git.status', {
const request = {
worktreePath,
...includeIgnoredArgs,
...upstreamCacheBypassArgs
})) as GitStatusResult
}
return (await (options?.signal
? this.mux.request('git.status', request, { signal: options.signal })
: this.mux.request('git.status', request))) as GitStatusResult
}
async checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise<string[]> {
@@ -102,6 +102,28 @@ describe('SshPtyProvider', () => {
})
})
it('forwards provider command delivery to the relay', async () => {
mux.request.mockResolvedValue({ id: 'pty-provider-command' })
await provider.spawn({
cols: 120,
rows: 40,
command: 'echo from-runtime',
commandDelivery: 'provider',
startupCommandDelivery: 'shell-ready'
})
expect(mux.request).toHaveBeenCalledWith('pty.spawn', {
cols: 120,
rows: 40,
cwd: undefined,
env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' },
command: 'echo from-runtime',
commandDelivery: 'provider',
startupCommandDelivery: 'shell-ready'
})
})
it('injects the relay-backed Orca CLI bridge into remote PTY env', async () => {
mux.request.mockResolvedValue({ id: 'pty-bridge' })
provider = new SshPtyProvider('conn-1', mux as never, {
+3 -5
View File
@@ -133,12 +133,10 @@ export class SshPtyProvider implements IPtyProvider {
cwd: opts.cwd,
env: this.withRemoteCliBridgeEnv(opts.env, opts.envToDelete),
// Why: the relay's plugin-overlay env augmenter needs to know which
// Pi-compatible agent is being launched (`pi` vs `omp`) so it mirrors
// the right `~/.<kind>/agent` source dir on the remote disk. The
// relay does not execute `command` itself — the user types it into
// the shell — but receiving it as a hint lets overlay resolution be
// per-launch instead of always-Pi.
// Pi-compatible agent is being launched, while commandDelivery tells it
// whether to submit the command itself for runtime-owned background PTYs.
...(opts.command ? { command: opts.command } : {}),
...(opts.commandDelivery ? { commandDelivery: opts.commandDelivery } : {}),
...(opts.startupCommandDelivery
? { startupCommandDelivery: opts.startupCommandDelivery }
: {})
+2
View File
@@ -30,6 +30,7 @@ export type PtySpawnOptions = {
env?: Record<string, string>
envToDelete?: string[]
command?: string
commandDelivery?: 'renderer' | 'provider'
startupCommandDelivery?: StartupCommandDelivery
/** Orca worktree identity. When present, the local provider scopes shell
* history to this worktree so ArrowUp only surfaces local commands. */
@@ -168,6 +169,7 @@ export type IFilesystemProvider = {
export type GitProviderStatusOptions = {
includeIgnored?: boolean
bypassEffectiveUpstreamNegativeCache?: boolean
signal?: AbortSignal
}
export type IGitProvider = {
+1
View File
@@ -6,6 +6,7 @@ import { areWorktreePathsEqual } from './ipc/worktree-logic'
type LocalRepoWorktreeListOptions = {
wslDistro?: string
signal?: AbortSignal
}
export function isRepoRoot(repos: Repo[], resolvedTarget: string): boolean {
+339 -20
View File
@@ -66,6 +66,7 @@ import {
} from '../../shared/constants'
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
import { makePaneKey } from '../../shared/stable-pane-id'
import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing'
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id'
import { RpcDispatcher } from './rpc/dispatcher'
import type { RpcRequest } from './rpc/core'
@@ -2911,7 +2912,13 @@ describe('OrcaRuntimeService', () => {
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-1'
connectionId: 'ssh-1',
hookSettings: {
mode: 'auto' as const,
setupRunPolicy: 'run-by-default' as const,
setupAgentStartupPolicy: 'wait-for-setup' as const,
scripts: { setup: '', archive: '' }
}
}
const parent = {
path: '/remote/repo-parent',
@@ -3078,7 +3085,13 @@ describe('OrcaRuntimeService', () => {
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-1'
connectionId: 'ssh-1',
hookSettings: {
mode: 'auto' as const,
setupRunPolicy: 'run-by-default' as const,
setupAgentStartupPolicy: 'wait-for-setup' as const,
scripts: { setup: '', archive: '' }
}
}
const metaById: Record<string, WorktreeMeta> = {}
const remoteStore = {
@@ -3288,7 +3301,13 @@ describe('OrcaRuntimeService', () => {
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-1'
connectionId: 'ssh-1',
hookSettings: {
mode: 'auto' as const,
setupRunPolicy: 'run-by-default' as const,
setupAgentStartupPolicy: 'wait-for-setup' as const,
scripts: { setup: '', archive: '' }
}
}
const metaById: Record<string, WorktreeMeta> = {}
const remoteStore = {
@@ -3375,15 +3394,16 @@ describe('OrcaRuntimeService', () => {
startup: { command: 'claude' }
})
expect(result.setup).toMatchObject({
runnerScriptPath: '/remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh'
})
// Why: runtime now provisions setup itself (fire-and-forget) and omits it
// from the RPC result so the caller does not double-spawn. The async spawn
// means we wait for both the agent and setup PTYs before asserting.
expect(result.setup).toBeUndefined()
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
expect(spawn).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
cwd: '/remote/mobile-setup',
command: 'claude',
command: expect.stringContaining('exec claude'),
worktreeId: result.worktree.id
})
)
@@ -3391,10 +3411,21 @@ describe('OrcaRuntimeService', () => {
2,
expect.objectContaining({
cwd: '/remote/mobile-setup',
command: 'bash /remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh',
command: expect.stringContaining(
'bash /remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh'
),
worktreeId: result.worktree.id
})
)
const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command
const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command
const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/)
expect(nonceMatch?.[1]).toBeTruthy()
const markerPath = `/remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh.${nonceMatch![1]}.done`
expect(setupCommand).toContain('printf')
expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`)
expect(startupCommand).toContain(markerPath)
expect(setupCommand).toContain(markerPath)
expect(revealTerminalSession).toHaveBeenLastCalledWith(
result.worktree.id,
expect.objectContaining({
@@ -6424,6 +6455,7 @@ describe('OrcaRuntimeService', () => {
expect.objectContaining({
cwd: TEST_WORKTREE_PATH,
command: 'codex',
commandDelivery: 'provider',
worktreeId: TEST_WORKTREE_ID,
preAllocatedHandle: expect.stringMatching(/^term_/)
})
@@ -6704,6 +6736,49 @@ describe('OrcaRuntimeService', () => {
})
})
it('preserves Claude Agent Teams for sequenced Claude launches', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
const runtimeStore = {
...store,
getSettings: () => ({
...store.getSettings(),
claudeAgentTeamsMode: 'in-process' as const
})
}
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command:
'bash -lc \'echo Waiting for setup to finish before starting agent... >&2; exec claude "hello"\'',
claudeAgentTeamsSourceCommand: 'claude "hello"',
launchAgent: 'claude',
launchConfig: {
agentCommand: 'claude',
agentArgs: '',
agentEnv: { CLAUDE_PROFILE: 'captured' }
}
})
const sequencedClaude = spawn.mock.calls[0]?.[0] as {
command?: string
env?: Record<string, string>
}
expect(sequencedClaude.command).toBe(
'bash -lc \'echo Waiting for setup to finish before starting agent... >&2; exec claude "hello"\''
)
expect(sequencedClaude.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBe('1')
expect(sequencedClaude.env?.[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]).toBe(
'claude --teammate-mode in-process "hello"'
)
})
it('restores captured native Claude Agent Teams mode with fresh service env', async () => {
setPlatform('linux')
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
@@ -18704,15 +18779,9 @@ describe('OrcaRuntimeService', () => {
repoId: 'repo-1',
path: '/tmp/workspaces/runtime-hook-skip',
branch: 'runtime-hook-skip'
}),
setup: {
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
envVars: {
ORCA_ROOT_PATH: '/tmp/repo',
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-hook-skip'
}
}
})
})
expect(result.setup).toBeUndefined()
expect(activateWorktree).not.toHaveBeenCalled()
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
expect(spawn).toHaveBeenNthCalledWith(
@@ -18754,6 +18823,166 @@ describe('OrcaRuntimeService', () => {
})
})
it('sequences setup before startup for opted-in local headless worktree creates', async () => {
const waitRepo = {
...store.getRepo('repo-1')!,
hookSettings: {
mode: 'auto' as const,
setupRunPolicy: 'run-by-default' as const,
setupAgentStartupPolicy: 'wait-for-setup' as const,
scripts: { setup: '', archive: '' }
}
}
const runtimeStore = {
...store,
getRepos: () => [waitRepo],
getRepo: (id: string) => (id === 'repo-1' ? waitRepo : undefined)
}
const runtime = new OrcaRuntimeService(runtimeStore as never)
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-headless-startup' })
const spawn = vi
.fn()
.mockResolvedValueOnce({ id: 'pty-headless-startup' })
.mockResolvedValueOnce({ id: 'pty-headless-setup' })
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession,
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-headless-startup-setup')
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-headless-startup-setup')
vi.mocked(getEffectiveHooks).mockReturnValue({
scripts: {
setup: 'pnpm worktree:setup'
}
})
vi.mocked(shouldRunSetupForCreate).mockReturnValue(true)
vi.mocked(createSetupRunnerScript).mockReturnValue({
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
envVars: {
ORCA_ROOT_PATH: '/tmp/repo',
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-headless-startup-setup'
},
waitForAgentStartup: true
})
vi.mocked(listWorktrees).mockResolvedValue([
{
path: '/tmp/workspaces/runtime-headless-startup-setup',
head: 'def',
branch: 'runtime-headless-startup-setup',
isBare: false,
isMainWorktree: false
}
])
const result = await runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'runtime-headless-startup-setup',
setupDecision: 'run',
startup: { command: 'claude' }
})
expect(createSetupRunnerScript).toHaveBeenCalled()
expect(runHook).not.toHaveBeenCalled()
// Why: runtime now provisions setup fire-and-forget, so the setup PTY spawns
// on a later tick. The wait-for-setup guarantee is enforced by the shell
// nonce/marker in the wrapped commands below, not by JS spawn ordering.
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command
const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command
const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/)
expect(nonceMatch?.[1]).toBeTruthy()
expect(startupCommand).toContain('exec claude')
expect(setupCommand).toContain('printf')
expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`)
expect(result.setup).toBeUndefined()
})
it('starts setup and startup side by side by default for local headless worktree creates', async () => {
const runtime = new OrcaRuntimeService(store)
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-headless-parallel' })
const spawn = vi
.fn()
.mockResolvedValueOnce({ id: 'pty-headless-parallel-startup' })
.mockResolvedValueOnce({ id: 'pty-headless-parallel-setup' })
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession,
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-headless-parallel')
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-headless-parallel')
vi.mocked(getEffectiveHooks).mockReturnValue({
scripts: {
setup: 'pnpm worktree:setup'
}
})
vi.mocked(shouldRunSetupForCreate).mockReturnValue(true)
vi.mocked(createSetupRunnerScript).mockReturnValue({
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
envVars: {
ORCA_ROOT_PATH: '/tmp/repo',
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-headless-parallel'
}
})
vi.mocked(listWorktrees).mockResolvedValue([
{
path: '/tmp/workspaces/runtime-headless-parallel',
head: 'def',
branch: 'runtime-headless-parallel',
isBare: false,
isMainWorktree: false
}
])
await runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'runtime-headless-parallel',
setupDecision: 'run',
startup: { command: 'claude' }
})
// Why: setup now spawns fire-and-forget on a later tick; wait for both PTYs.
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
expect(spawn).toHaveBeenNthCalledWith(1, expect.objectContaining({ command: 'claude' }))
expect(spawn).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ command: 'bash /tmp/repo/.git/orca/setup-runner.sh' })
)
})
it('creates the first terminal for CLI-created worktrees without activating them', async () => {
const runtime = new OrcaRuntimeService(store)
const activateWorktree = vi.fn()
@@ -19449,7 +19678,7 @@ describe('OrcaRuntimeService', () => {
expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'claude' })
})
it('honors split setup placement for local startup-draft worktrees', async () => {
it('honors split setup placement for opted-in local startup-draft worktrees', async () => {
const metaById: Record<string, WorktreeMeta> = {}
const runtimeStore = {
...store,
@@ -19506,7 +19735,8 @@ describe('OrcaRuntimeService', () => {
envVars: {
ORCA_ROOT_PATH: '/tmp/repo',
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-split'
}
},
waitForAgentStartup: true
})
vi.mocked(listWorktrees).mockResolvedValue([
{
@@ -19531,7 +19761,7 @@ describe('OrcaRuntimeService', () => {
1,
expect.objectContaining({
cwd: '/tmp/workspaces/runtime-startup-setup-split',
command: "codex '--dangerously-bypass-approvals-and-sandbox'",
command: expect.stringContaining('codex'),
worktreeId: result.worktree.id
})
)
@@ -19539,7 +19769,7 @@ describe('OrcaRuntimeService', () => {
2,
expect.objectContaining({
cwd: '/tmp/workspaces/runtime-startup-setup-split',
command: 'bash /tmp/repo/.git/orca/setup-runner.sh',
command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh'),
env: expect.objectContaining({
ORCA_ROOT_PATH: '/tmp/repo',
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-split',
@@ -19548,8 +19778,19 @@ describe('OrcaRuntimeService', () => {
worktreeId: result.worktree.id
})
)
const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command
const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command
const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/)
expect(nonceMatch?.[1]).toBeTruthy()
const markerPath = `/tmp/repo/.git/orca/setup-runner.sh.${nonceMatch![1]}.done`
expect(startupCommand).toContain('--dangerously-bypass-approvals-and-sandbox')
expect(setupCommand).toContain('printf')
expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`)
expect(startupCommand).toContain(markerPath)
expect(setupCommand).toContain(markerPath)
const mainEnv = (spawn.mock.calls[0]![0] as { env?: Record<string, string> }).env ?? {}
const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record<string, string> }).env ?? {}
expect(result.setup).toBeUndefined()
expect(mainEnv.ORCA_TAB_ID).toBeDefined()
expect(mainEnv.ORCA_PANE_KEY).toBeDefined()
expect(setupEnv.ORCA_TAB_ID).toBe(mainEnv.ORCA_TAB_ID)
@@ -19566,6 +19807,84 @@ describe('OrcaRuntimeService', () => {
)
})
it('passes the wrapped setup command to activation when startup spawned but setup did not', async () => {
const runtime = new OrcaRuntimeService(store)
const spawn = vi
.fn()
.mockResolvedValueOnce({ id: 'pty-startup-main' })
.mockRejectedValueOnce(new Error('setup spawn failed'))
const activateWorktree = vi.fn()
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree,
createTerminal: vi.fn(),
revealTerminalSession: vi.fn().mockResolvedValue({ tabId: 'tab-startup-main' }),
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
runtime.attachWindow(1)
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-startup-setup-retry')
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-startup-setup-retry')
vi.mocked(getEffectiveHooks).mockReturnValue({
scripts: {
setup: 'pnpm worktree:setup'
}
})
vi.mocked(shouldRunSetupForCreate).mockReturnValue(true)
vi.mocked(createSetupRunnerScript).mockReturnValue({
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
envVars: {
ORCA_ROOT_PATH: '/tmp/repo',
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-retry'
},
waitForAgentStartup: true
})
vi.mocked(listWorktrees).mockResolvedValue([
{
path: '/tmp/workspaces/runtime-startup-setup-retry',
head: 'def',
branch: 'runtime-startup-setup-retry',
isBare: false,
isMainWorktree: false
}
])
await runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'runtime-startup-setup-retry',
setupDecision: 'run',
activate: true,
startup: { command: 'claude' }
})
expect(spawn).toHaveBeenCalledTimes(2)
expect(activateWorktree).toHaveBeenCalledWith(
'repo-1',
expect.any(String),
expect.objectContaining({
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh')
}),
undefined,
undefined
)
const activationSetup = activateWorktree.mock.calls[0]?.[2] as { command?: string } | undefined
expect(activationSetup?.command).toContain('printf')
})
it('lets explicit startup draft agents override the desktop default', async () => {
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([])
const metaById: Record<string, WorktreeMeta> = {}
+216 -43
View File
@@ -154,7 +154,14 @@ import { parsePtySessionId } from '../../shared/pty-session-id-format'
import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits'
import { isFolderRepo } from '../../shared/repo-kind'
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
import { buildSetupRunnerCommand } from '../../shared/setup-runner-command'
import {
buildSetupRunnerCommand,
getSetupRunnerCommandPlatformForPath
} from '../../shared/setup-runner-command'
import {
createSequencedSetupAgentCommands,
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV
} from '../../shared/setup-agent-sequencing'
import { TASK_PROVIDERS } from '../../shared/task-providers'
import { FIRST_PANE_ID } from '../../shared/pane-key'
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id'
@@ -991,6 +998,7 @@ type RuntimePtyController = {
rows: number
cwd?: string
command?: string
commandDelivery?: 'renderer' | 'provider'
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
env?: Record<string, string>
envToDelete?: string[]
@@ -9821,10 +9829,15 @@ export class OrcaRuntimeService {
| 'issueSourcePreference'
| 'externalWorktreeVisibility'
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
| 'projectGroupId'
| 'projectGroupOrder'
>
> & { sourceControlAi?: Repo['sourceControlAi'] | null }
> & {
sourceControlAi?: Repo['sourceControlAi'] | null
externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null
}
): Promise<Repo> {
if (!this.store) {
throw new Error('runtime_unavailable')
@@ -9834,6 +9847,12 @@ export class OrcaRuntimeService {
if ('worktreeBasePath' in updates && updates.worktreeBasePath === undefined) {
sanitizedUpdates.worktreeBasePath = undefined
}
if (
'externalWorktreeDiscoverySuppressedAt' in updates &&
updates.externalWorktreeDiscoverySuppressedAt === null
) {
sanitizedUpdates.externalWorktreeDiscoverySuppressedAt = undefined
}
if ('sourceControlAi' in updates && updates.sourceControlAi === null) {
sanitizedUpdates.sourceControlAi = null
}
@@ -11961,10 +11980,16 @@ export class OrcaRuntimeService {
primaryTerminalHandle?: string | null
hasStartupTerminal: boolean
setupCommandPlatform: 'windows' | 'posix'
}): Promise<void> {
// Why: when the agent startup is sequenced to wait for setup
// (waitForAgentStartup), the startup PTY runs a wrapper that already embeds
// the setup command. Pass that wrapped command through so the Setup tab runs
// the same script the agent is waiting on instead of a bare runner.
wrappedSetupCommand?: string
}): Promise<{ setupSpawned: boolean }> {
if (!this.ptyController?.spawn) {
return
return { setupSpawned: false }
}
let setupSpawned = false
try {
const defaultTabHandles = await this.createDefaultTabTerminals(
args.worktreeSelector,
@@ -11983,10 +12008,9 @@ export class OrcaRuntimeService {
primaryTerminalHandle = terminal.handle
}
if (args.setup) {
const setupCommand = buildSetupRunnerCommand(
args.setup.runnerScriptPath,
args.setupCommandPlatform
)
const setupCommand =
args.wrappedSetupCommand ??
buildSetupRunnerCommand(args.setup.runnerScriptPath, args.setupCommandPlatform)
const shouldSplitSetup =
primaryTerminalHandle &&
(setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal')
@@ -12002,6 +12026,7 @@ export class OrcaRuntimeService {
command: setupCommand,
env: args.setup.envVars
}))
setupSpawned = true
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
@@ -12009,6 +12034,7 @@ export class OrcaRuntimeService {
`[worktree-create] Failed to create setup/default terminals for ${args.worktreePath}: ${message}`
)
}
return { setupSpawned }
}
private async waitForStartupFollowupReady(
@@ -12799,12 +12825,11 @@ export class OrcaRuntimeService {
}
const shouldRunSetup = hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision)
if (shouldRunSetup && hooks?.scripts.setup) {
if (this.authoritativeWindowId !== null) {
const shouldUseSetupRunner = this.authoritativeWindowId !== null || Boolean(effectiveStartup)
if (shouldUseSetupRunner) {
try {
// Why: CLI-created worktrees must use the same runner-script path as the
// renderer create flow so repo-committed `orca.yaml` setup hooks run in
// the visible first terminal instead of a hidden background shell with
// different failure and prompt behavior.
// Why: setup+startup must share the terminal runner path even without
// a renderer window, so the startup shell can wait on setup completion.
setup = createSetupRunnerScript(
repo,
worktreePath,
@@ -12847,11 +12872,39 @@ export class OrcaRuntimeService {
this.notifyWorktreesChanged(repo.id)
const shouldActivate = args.activate === true || args.runHooks === true
let didSpawnStartup = false
// Why: tracks whether runtime itself launched the setup script (via
// provisionManagedWorktreeTerminals). When true, renderer activation and the
// RPC return value must omit setup so the client does not spawn it a second
// time. Mirrors the wait-for-agent setup contract from #6298.
let didSpawnSetup = false
let startupTerminalHandle: string | null = null
let startupTerminalTabId: string | null = null
let startupTerminalPaneKey: string | null = null
let startupTerminalPtyId: string | null = null
if (effectiveStartup && this.ptyController?.spawn) {
let sequencedStartup = effectiveStartup
let wrappedSetupCommandStr: string | undefined
if (effectiveStartup && setup?.waitForAgentStartup === true) {
const platform = getSetupRunnerCommandPlatformForPath(
setup.runnerScriptPath,
process.platform === 'win32' ? 'windows' : 'posix'
)
const sequenced = createSequencedSetupAgentCommands({
runnerScriptPath: setup.runnerScriptPath,
startupCommand: effectiveStartup.command,
platform
})
sequencedStartup = {
...effectiveStartup,
command: sequenced.startupCommand,
...(sequenced.startupEnv
? { env: { ...effectiveStartup.env, ...sequenced.startupEnv } }
: {})
}
wrappedSetupCommandStr = sequenced.setupCommand
}
if (sequencedStartup && this.ptyController?.spawn) {
try {
// Why: automation startup must not depend on a renderer TerminalPane
// mounting. Runtime-spawned PTYs run immediately and the UI adopts the
@@ -12861,12 +12914,15 @@ export class OrcaRuntimeService {
this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktreePath)
}
const terminal = await this.createTerminal(`id:${worktree.id}`, {
command: effectiveStartup.command,
env: effectiveStartup.env,
...(effectiveStartup.launchConfig ? { launchConfig: effectiveStartup.launchConfig } : {}),
command: sequencedStartup.command,
...(setup && effectiveStartup
? { claudeAgentTeamsSourceCommand: effectiveStartup.command }
: {}),
env: sequencedStartup.env,
...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}),
...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}),
startupCommandDelivery: effectiveStartup.startupCommandDelivery,
telemetry: effectiveStartup.telemetry
startupCommandDelivery: sequencedStartup.startupCommandDelivery,
telemetry: sequencedStartup.telemetry
})
if (effectiveDraftPaste) {
this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste)
@@ -12894,9 +12950,11 @@ export class OrcaRuntimeService {
const runtimeWillProvisionTerminals = didSpawnStartup && Boolean(setup || defaultTabs)
if (runtimeWillProvisionTerminals) {
// Why: once runtime spawned the startup PTY, renderer activation may see
// an existing terminal and skip setup/default tabs. Keep those side
// effects best-effort so they cannot delay the startup agent.
void this.provisionManagedWorktreeTerminals({
// an existing terminal and skip setup/default tabs. Await provisioning so
// a failed setup spawn falls back to renderer activation (which still
// carries the wrapped command for retry); #6298's wait-for-setup
// guarantee is enforced by the shell marker, not by spawn timing.
const provisioned = await this.provisionManagedWorktreeTerminals({
worktreeSelector: `id:${worktree.id}`,
worktreeId: worktree.id,
worktreePath,
@@ -12908,10 +12966,26 @@ export class OrcaRuntimeService {
? isWindowsAbsolutePathLike(setup.runnerScriptPath)
? 'windows'
: 'posix'
: 'posix'
: 'posix',
// Why: carry the wait-for-agent wrapped setup command (#6298) so the
// Setup tab runs the same script the sequenced agent waits on.
...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {})
})
didSpawnSetup = provisioned.setupSpawned
}
const activationSetup = runtimeWillProvisionTerminals ? undefined : setup
// Why: when runtime spawned setup, omit it from activation. When setup
// spawn failed, fall through with the wrapped command so renderer
// activation retries it.
const activationSetup = didSpawnSetup
? undefined
: setup
? {
...setup,
...(didSpawnStartup && wrappedSetupCommandStr
? { command: wrappedSetupCommandStr }
: {})
}
: undefined
const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : defaultTabs
if (effectiveStartup && !didSpawnStartup) {
this.notifyActivateWorktree(
@@ -12945,8 +13019,14 @@ export class OrcaRuntimeService {
? isWindowsAbsolutePathLike(setup.runnerScriptPath)
? 'windows'
: 'posix'
: 'posix'
: 'posix',
...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {})
})
// Why: runtime owns setup spawning here, so the RPC result must omit setup
// to keep the headless/mobile caller from launching it a second time.
if (setup) {
didSpawnSetup = true
}
} else if (this.ptyController?.spawn) {
try {
await this.createTerminal(`id:${worktree.id}`)
@@ -12958,6 +13038,16 @@ export class OrcaRuntimeService {
console.warn(`[worktree-create] ${warning}`)
}
}
const returnedSetup = didSpawnSetup
? undefined
: setup
? {
...setup,
...(didSpawnStartup && wrappedSetupCommandStr
? { command: wrappedSetupCommandStr }
: {})
}
: undefined
return {
worktree: {
...worktree,
@@ -12968,7 +13058,7 @@ export class OrcaRuntimeService {
git: created
},
...(lineageInput ? { lineage, workspaceLineage, warnings: lineageWarnings } : {}),
...(setup ? { setup } : {}),
...(returnedSetup ? { setup: returnedSetup } : {}),
...(defaultTabs ? { defaultTabs } : {}),
...(warning ? { warning } : {}),
...(addResult.localBaseRefRefresh
@@ -13089,11 +13179,32 @@ export class OrcaRuntimeService {
let warning = result.warning
let didSpawnStartup = false
// Why: same no-double-spawn contract as the local path — once runtime
// provisions setup, omit it from activation and the RPC result.
let didSpawnSetup = false
let startupTerminalHandle: string | null = null
let startupTerminalTabId: string | null = null
let startupTerminalPaneKey: string | null = null
let startupTerminalPtyId: string | null = null
if (args.startup && this.ptyController?.spawn) {
let sequencedStartup = args.startup
let wrappedSetupCommandStr: string | undefined
if (args.startup && result.setup?.waitForAgentStartup === true) {
const platform = getSetupRunnerCommandPlatformForPath(result.setup.runnerScriptPath, 'posix')
const sequenced = createSequencedSetupAgentCommands({
runnerScriptPath: result.setup.runnerScriptPath,
startupCommand: args.startup.command,
platform
})
sequencedStartup = {
...args.startup,
command: sequenced.startupCommand,
...(sequenced.startupEnv ? { env: { ...args.startup.env, ...sequenced.startupEnv } } : {})
}
wrappedSetupCommandStr = sequenced.setupCommand
}
if (sequencedStartup && this.ptyController?.spawn) {
try {
const startupTrustAgent = args.startupDraftPaste?.agent ?? args.createdWithAgent
if (startupTrustAgent) {
@@ -13104,12 +13215,15 @@ export class OrcaRuntimeService {
)
}
const terminal = await this.createTerminal(`path:${result.worktree.path}`, {
command: args.startup.command,
env: args.startup.env,
...(args.startup.launchConfig ? { launchConfig: args.startup.launchConfig } : {}),
command: sequencedStartup.command,
...(result.setup && args.startup
? { claudeAgentTeamsSourceCommand: args.startup.command }
: {}),
env: sequencedStartup.env,
...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}),
...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}),
startupCommandDelivery: args.startup.startupCommandDelivery,
telemetry: args.startup.telemetry
startupCommandDelivery: sequencedStartup.startupCommandDelivery,
telemetry: sequencedStartup.telemetry
})
if (args.startupDraftPaste) {
this.pasteStartupDraftWhenReady(terminal.handle, args.startupDraftPaste)
@@ -13136,8 +13250,9 @@ export class OrcaRuntimeService {
didSpawnStartup && Boolean(result.setup || result.defaultTabs)
if (runtimeWillProvisionTerminals) {
// Why: remote/mobile task creates spawn the agent terminal in runtime,
// so renderer activation may not materialize setup/default tabs.
void this.provisionManagedWorktreeTerminals({
// so renderer activation may not materialize setup/default tabs. Await so
// a failed setup spawn falls back to renderer activation for retry.
const provisioned = await this.provisionManagedWorktreeTerminals({
worktreeSelector: `path:${result.worktree.path}`,
worktreeId: result.worktree.id,
worktreePath: result.worktree.path,
@@ -13149,10 +13264,25 @@ export class OrcaRuntimeService {
? isWindowsAbsolutePathLike(result.setup.runnerScriptPath)
? 'windows'
: 'posix'
: 'posix'
: 'posix',
// Why: carry the wait-for-agent wrapped setup command (#6298) so the
// remote Setup tab runs the same script the sequenced agent waits on.
...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {})
})
didSpawnSetup = provisioned.setupSpawned
}
const activationSetup = runtimeWillProvisionTerminals ? undefined : result.setup
// Why: omit setup from activation when runtime spawned it; on spawn
// failure fall through with the wrapped command so renderer retries.
const activationSetup = didSpawnSetup
? undefined
: result.setup
? {
...result.setup,
...(didSpawnStartup && wrappedSetupCommandStr
? { command: wrappedSetupCommandStr }
: {})
}
: undefined
const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : result.defaultTabs
if (args.startup && !didSpawnStartup) {
this.notifyActivateWorktree(
@@ -13192,8 +13322,14 @@ export class OrcaRuntimeService {
? isWindowsAbsolutePathLike(result.setup.runnerScriptPath)
? 'windows'
: 'posix'
: 'posix'
: 'posix',
...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {})
})
// Why: runtime owns setup spawning here, so omit setup from the RPC result
// to keep the headless/mobile caller from launching it a second time.
if (result.setup) {
didSpawnSetup = true
}
} else if (!shouldActivate && this.ptyController?.spawn) {
try {
await this.createTerminal(`path:${result.worktree.path}`)
@@ -13205,10 +13341,27 @@ export class OrcaRuntimeService {
}
}
const returnedSetup = didSpawnSetup
? undefined
: result.setup
? {
...result.setup,
...(didSpawnStartup && wrappedSetupCommandStr
? { command: wrappedSetupCommandStr }
: {})
}
: undefined
const resultForRenderer = returnedSetup
? { ...result, setup: returnedSetup }
: (() => {
const { setup: _setup, ...resultWithoutSetup } = result
return resultWithoutSetup
})()
const resultWithStartupTerminal =
didSpawnStartup && startupTerminalHandle
? {
...result,
...resultForRenderer,
startupTerminal: {
spawned: true,
handle: startupTerminalHandle,
@@ -13218,7 +13371,7 @@ export class OrcaRuntimeService {
surface: 'background' as const
}
}
: result
: resultForRenderer
return warning ? { ...resultWithStartupTerminal, warning } : resultWithStartupTerminal
}
@@ -14717,6 +14870,7 @@ export class OrcaRuntimeService {
worktreeSelector?: string,
opts: {
command?: string
claudeAgentTeamsSourceCommand?: string
env?: Record<string, string>
launchConfig?: WorktreeStartupLaunch['launchConfig']
launchToken?: string
@@ -14778,14 +14932,16 @@ export class OrcaRuntimeService {
...opts.env,
...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {})
}
const claudeAgentTeamsSourceCommand =
opts.claudeAgentTeamsSourceCommand?.trim() || opts.command?.trim() || undefined
const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode
const effectiveClaudeAgentTeamsMode = inferCapturedClaudeAgentTeamsMode(
opts.launchConfig,
opts.command,
claudeAgentTeamsSourceCommand,
claudeAgentTeamsMode
)
const agentTeamsPlan = await buildClaudeAgentTeamsLaunchPlan({
command: opts.command,
command: claudeAgentTeamsSourceCommand,
mode: effectiveClaudeAgentTeamsMode,
baseEnv: {
...process.env,
@@ -14802,6 +14958,13 @@ export class OrcaRuntimeService {
shimBin
}).env
})
const sequencedStartupCommand =
agentTeamsPlan &&
claudeAgentTeamsSourceCommand &&
opts.command &&
claudeAgentTeamsSourceCommand !== opts.command
? agentTeamsPlan.command
: undefined
const effectiveLaunchConfig =
opts.launchConfig && agentTeamsPlan
? {
@@ -14817,9 +14980,17 @@ export class OrcaRuntimeService {
}
}
: opts.launchConfig
// Why: setup/agent sequencing wraps the PTY launch in a wait shell before
// Claude Agent Teams runs. Preserve the direct Claude command separately
// so the wrapper can exec the teammate-mode variant after setup completes.
const env = this.buildTerminalWorkspaceEnv(
workspace,
baseEnv,
{
...baseEnv,
...(sequencedStartupCommand
? { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: sequencedStartupCommand }
: {})
},
paneKey,
tabId,
agentTeamsPlan?.env
@@ -14828,7 +14999,8 @@ export class OrcaRuntimeService {
cols: 120,
rows: 40,
cwd: workspace.path,
command: agentTeamsPlan?.command ?? opts.command,
command: sequencedStartupCommand ? opts.command : (agentTeamsPlan?.command ?? opts.command),
commandDelivery: 'provider',
startupCommandDelivery: opts.startupCommandDelivery,
env,
envToDelete: agentTeamsPlan?.envToDelete,
@@ -15673,6 +15845,7 @@ export class OrcaRuntimeService {
rows: 40,
cwd: workspace.path,
command: opts.command,
commandDelivery: 'provider',
env: this.buildTerminalWorkspaceEnv(workspace, opts.env ?? {}, paneKey, parentTabId),
envToDelete: opts.envToDelete,
connectionId: workspace.connectionId,
@@ -0,0 +1,63 @@
import { z } from 'zod'
import { OptionalFiniteNumber, OptionalString } from '../schemas'
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
export const RepoSourceControlAiOverrides = z
.unknown()
.optional()
.transform((value) =>
value === undefined
? undefined
: value === null
? null
: normalizeRepoSourceControlAiOverrides(value)
)
const RepoBadgeColor = z
.unknown()
.optional()
.transform((value) =>
value === undefined ? undefined : (normalizeRepoBadgeColor(value) ?? undefined)
)
const RepoUpstream = z
.object({
owner: z.string().min(1),
repo: z.string().min(1)
})
.nullable()
.optional()
export function createRepoUpdateSchema<T extends z.ZodRawShape>(
selectorShape: T
): z.ZodObject<T & { updates: z.ZodObject<z.ZodRawShape> }> {
return z.object({
...selectorShape,
updates: z.object({
displayName: OptionalString,
badgeColor: RepoBadgeColor,
repoIcon: z
.unknown()
.transform((value) => sanitizeRepoIcon(value))
.optional(),
upstream: RepoUpstream,
hookSettings: z.unknown().optional(),
worktreeBaseRef: OptionalString,
worktreeBasePath: OptionalString,
kind: z.enum(['git', 'folder']).optional(),
symlinkPaths: z.array(z.string()).optional(),
issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional(),
forkSyncMode: z.enum(['ask', 'safe-auto', 'off']).optional(),
externalWorktreeVisibility: z.enum(['hide', 'show']).optional(),
externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(),
externalWorktreeInboxBaselinePaths: z.array(z.string()).optional(),
importedExternalWorktreePaths: z.array(z.string()).optional(),
externalWorktreeDiscoverySuppressedAt: z.number().finite().nullable().optional(),
projectGroupId: OptionalString.nullable().optional(),
projectGroupOrder: OptionalFiniteNumber,
sourceControlAi: RepoSourceControlAiOverrides
})
}) as z.ZodObject<T & { updates: z.ZodObject<z.ZodRawShape> }>
}
+2 -52
View File
@@ -1,11 +1,9 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
import { PROJECT_RUNTIME_METHODS } from './project-runtime-rpc-methods'
import { FOLDER_WORKSPACE_METHODS } from './folder-workspace'
import { createRepoUpdateSchema } from './repo-update-schema'
const RepoSelector = z.object({
repo: requiredString('Missing repo selector')
@@ -32,55 +30,7 @@ const RepoSetBaseRef = z.object({
ref: requiredString('Missing base ref')
})
const RepoSourceControlAiOverrides = z
.unknown()
.optional()
.transform((value) =>
value === undefined
? undefined
: value === null
? null
: normalizeRepoSourceControlAiOverrides(value)
)
const RepoBadgeColor = z
.unknown()
.optional()
.transform((value) =>
value === undefined ? undefined : (normalizeRepoBadgeColor(value) ?? undefined)
)
const RepoUpstream = z
.object({
owner: z.string().min(1),
repo: z.string().min(1)
})
.nullable()
.optional()
const RepoUpdate = RepoSelector.extend({
updates: z.object({
displayName: OptionalString,
badgeColor: RepoBadgeColor,
repoIcon: z
.unknown()
.transform((value) => sanitizeRepoIcon(value))
.optional(),
upstream: RepoUpstream,
hookSettings: z.unknown().optional(),
worktreeBaseRef: OptionalString,
worktreeBasePath: OptionalString,
kind: z.enum(['git', 'folder']).optional(),
symlinkPaths: z.array(z.string()).optional(),
issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional(),
forkSyncMode: z.enum(['ask', 'safe-auto', 'off']).optional(),
externalWorktreeVisibility: z.enum(['hide', 'show']).optional(),
externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(),
projectGroupId: OptionalString.nullable().optional(),
projectGroupOrder: OptionalFiniteNumber,
sourceControlAi: RepoSourceControlAiOverrides
})
})
const RepoUpdate = createRepoUpdateSchema(RepoSelector.shape)
const RepoSearchRefs = z.object({
repo: requiredString('Missing repo selector'),
@@ -37,6 +37,10 @@ import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-rel
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection'
import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators'
import {
scheduleWorktreeBaseDirectoryWatcherSync,
setWorktreeBaseDirectoryWatcherSyncContext
} from '../ipc/worktree-base-directory-watcher'
let appReloadHandlerTokenCounter = 0
let activeAppReloadHandlerToken: number | null = null
@@ -60,6 +64,9 @@ export function attachMainWindowServices(
registerAppReloadHandler(mainWindow, options?.onBeforeRendererReload)
registerRepoHandlers(mainWindow, store)
registerWorktreeHandlers(mainWindow, store, runtime)
// Why: repo/settings mutations resync watchers through this attached main-window context.
setWorktreeBaseDirectoryWatcherSyncContext(store, mainWindow)
scheduleWorktreeBaseDirectoryWatcherSync(store, mainWindow)
registerWorkspaceCleanupHandlers(store, { runtime, getLocalPtyProvider })
registerPtyHandlers(
mainWindow,
+113 -2
View File
@@ -2551,10 +2551,12 @@ describe('createMainWindow', () => {
consoleError.mockRestore()
})
it('ignores duplicate ready-to-show events after startup maximize has already run', () => {
function createStartupRevealWindowFixture() {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
on: vi.fn(),
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
@@ -2581,6 +2583,23 @@ describe('createMainWindow', () => {
return browserWindowInstance
})
return { browserWindowInstance, windowHandlers }
}
function createStartupRevealStore(savedMaximized: boolean) {
return {
getUI: () =>
({
windowMaximized: savedMaximized
}) as never,
getSettings: () => ({ windowBackgroundBlur: false }) as never,
updateUI: vi.fn()
}
}
it('ignores duplicate ready-to-show events after startup maximize has already run', () => {
const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture()
createMainWindow({
getUI: () =>
({
@@ -2597,6 +2616,98 @@ describe('createMainWindow', () => {
expect(browserWindowInstance.show).toHaveBeenCalledTimes(1)
})
it('reveals the startup window on Windows when ready-to-show never fires', () => {
vi.useFakeTimers()
const { browserWindowInstance } = createStartupRevealWindowFixture()
withPlatform('win32', () => {
createMainWindow(null)
vi.advanceTimersByTime(9_999)
expect(browserWindowInstance.show).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(browserWindowInstance.show).toHaveBeenCalledTimes(1)
})
})
it('cancels the Windows startup reveal fallback after ready-to-show', () => {
vi.useFakeTimers()
const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture()
withPlatform('win32', () => {
createMainWindow(null)
windowHandlers['ready-to-show']()
vi.advanceTimersByTime(10_000)
expect(browserWindowInstance.show).toHaveBeenCalledTimes(1)
})
})
it('does not install the startup reveal fallback off Windows', () => {
vi.useFakeTimers()
const { browserWindowInstance } = createStartupRevealWindowFixture()
withPlatform('linux', () => {
createMainWindow(null)
vi.advanceTimersByTime(10_000)
expect(browserWindowInstance.show).not.toHaveBeenCalled()
expect(browserWindowInstance.maximize).not.toHaveBeenCalled()
})
})
it('keeps the headless E2E window hidden when the Windows fallback fires', () => {
vi.useFakeTimers()
const previousHeadless = process.env.ORCA_E2E_HEADLESS
process.env.ORCA_E2E_HEADLESS = '1'
const { browserWindowInstance } = createStartupRevealWindowFixture()
try {
withPlatform('win32', () => {
createMainWindow(createStartupRevealStore(true) as never)
vi.advanceTimersByTime(10_000)
expect(browserWindowInstance.show).not.toHaveBeenCalled()
expect(browserWindowInstance.maximize).not.toHaveBeenCalled()
})
} finally {
if (previousHeadless === undefined) {
delete process.env.ORCA_E2E_HEADLESS
} else {
process.env.ORCA_E2E_HEADLESS = previousHeadless
}
}
})
it('clears the Windows startup reveal fallback when the window is closed', () => {
vi.useFakeTimers()
const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture()
withPlatform('win32', () => {
createMainWindow(createStartupRevealStore(true) as never)
windowHandlers.closed()
vi.advanceTimersByTime(10_000)
expect(browserWindowInstance.show).not.toHaveBeenCalled()
expect(browserWindowInstance.maximize).not.toHaveBeenCalled()
})
})
it('does not show or maximize a destroyed window when the Windows fallback fires', () => {
vi.useFakeTimers()
const { browserWindowInstance } = createStartupRevealWindowFixture()
withPlatform('win32', () => {
createMainWindow(createStartupRevealStore(true) as never)
browserWindowInstance.isDestroyed.mockReturnValue(true)
vi.advanceTimersByTime(10_000)
expect(browserWindowInstance.show).not.toHaveBeenCalled()
expect(browserWindowInstance.maximize).not.toHaveBeenCalled()
})
})
describe('minimize to tray on close (win32)', () => {
const originalPlatform = process.platform
+27 -2
View File
@@ -330,11 +330,34 @@ export function createMainWindow(
// handler re-runs maximize() from the persisted savedMaximized flag, snapping
// the window back to full-screen after the user already resized it (#591).
let handledInitialReadyToShow = false
mainWindow.on('ready-to-show', () => {
let initialRevealFallbackTimer: ReturnType<typeof setTimeout> | null =
process.platform === 'win32'
? setTimeout(() => {
// Why: GPU/driver failures on Windows can prevent ready-to-show forever,
// leaving the only app window hidden while the main process stays alive.
initialRevealFallbackTimer = null
revealInitialWindow()
}, 10_000)
: null
initialRevealFallbackTimer?.unref?.()
const clearInitialRevealFallbackTimer = (): void => {
if (initialRevealFallbackTimer) {
clearTimeout(initialRevealFallbackTimer)
initialRevealFallbackTimer = null
}
}
const revealInitialWindow = (): void => {
if (mainWindow.isDestroyed()) {
clearInitialRevealFallbackTimer()
return
}
if (handledInitialReadyToShow) {
return
}
handledInitialReadyToShow = true
clearInitialRevealFallbackTimer()
// Why: in E2E headless mode, the window stays hidden to avoid stealing
// focus and screen real estate during test runs. Playwright interacts
@@ -347,7 +370,8 @@ export function createMainWindow(
mainWindow.maximize()
}
mainWindow.show()
})
}
mainWindow.on('ready-to-show', revealInitialWindow)
// Why: persist window bounds so the app restores to the user's last
// position/size instead of maximizing on every launch. Debounce to avoid
@@ -1117,6 +1141,7 @@ export function createMainWindow(
ipcMain.on(confirmCloseChannel, onConfirmClose)
mainWindow.on('closed', () => {
clearInitialRevealFallbackTimer()
// Why: default-deny the Cmd+B carve-out after the window is gone so a
// stale-true flag can't leak past subsequent state transitions. Paired
// with the webContents lifecycle resets above.
+11 -2
View File
@@ -391,6 +391,7 @@ import type {
WorkspaceCleanupLocalProcessArgs,
WorkspaceCleanupLocalProcessResult,
WorkspaceCleanupScanArgs,
WorkspaceCleanupScanProgress,
WorkspaceCleanupScanResult
} from '../shared/workspace-cleanup'
import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings'
@@ -788,11 +789,16 @@ export type PreloadApi = {
| 'issueSourcePreference'
| 'externalWorktreeVisibility'
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
| 'projectGroupId'
| 'projectGroupOrder'
| 'forkSyncMode'
>
> & { sourceControlAi?: Repo['sourceControlAi'] | null }
> & {
sourceControlAi?: Repo['sourceControlAi'] | null
externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null
}
}) => Promise<Repo>
pickFolder: () => Promise<string | null>
pickFolders: () => Promise<string[]>
@@ -989,7 +995,10 @@ export type PreloadApi = {
) => () => void
}
workspaceCleanup: {
scan: (args?: WorkspaceCleanupScanArgs) => Promise<WorkspaceCleanupScanResult>
scan: (
args?: WorkspaceCleanupScanArgs,
onProgress?: (progress: WorkspaceCleanupScanProgress) => void
) => Promise<WorkspaceCleanupScanResult>
dismiss: (args: WorkspaceCleanupDismissArgs) => Promise<void>
clearDismissals: () => Promise<void>
hasKillableLocalProcesses: (
+19 -1
View File
@@ -79,6 +79,7 @@ import type {
RateLimitState
} from '../shared/rate-limit-types'
import type { WorkspaceSpaceScanProgress } from '../shared/workspace-space-types'
import type { WorkspaceCleanupScanProgress } from '../shared/workspace-cleanup'
import type { WorkspacePortAdvertisedUrlChangedEvent } from '../shared/workspace-ports'
import type { GhAuthDiagnostic } from '../shared/github-auth-types'
import type { TaskSourceContext } from '../shared/task-source-context'
@@ -662,7 +663,24 @@ const api = {
} satisfies PreloadApi['worktrees'],
workspaceCleanup: {
scan: (args) => ipcRenderer.invoke('workspaceCleanup:scan', args),
scan: (args, onProgress) => {
if (!onProgress) {
return ipcRenderer.invoke('workspaceCleanup:scan', args)
}
const scanId = args?.scanId ?? crypto.randomUUID()
const listener = (
_event: Electron.IpcRendererEvent,
progress: WorkspaceCleanupScanProgress
): void => {
if (progress.scanId === scanId) {
onProgress(progress)
}
}
ipcRenderer.on('workspaceCleanup:scanProgress', listener)
return ipcRenderer
.invoke('workspaceCleanup:scan', { ...args, scanId })
.finally(() => ipcRenderer.removeListener('workspaceCleanup:scanProgress', listener))
},
dismiss: (args) => ipcRenderer.invoke('workspaceCleanup:dismiss', args),
clearDismissals: () => ipcRenderer.invoke('workspaceCleanup:clearDismissals'),
hasKillableLocalProcesses: (args) =>
+91
View File
@@ -1660,6 +1660,97 @@ describe('GitHandler', () => {
expect(result[0].isMainWorktree).toBe(true)
})
it.skipIf(process.platform === 'win32')(
'normalizes the main worktree path for a separate-git-dir repo',
async () => {
const sourcePath = path.join(tmpDir, 'source')
const worktreePath = path.join(tmpDir, 'worktree')
const gitDirPath = path.join(tmpDir, 'git-store', 'project.git')
mkdirSync(sourcePath)
mkdirSync(path.dirname(gitDirPath), { recursive: true })
gitInit(sourcePath)
writeFileSync(path.join(sourcePath, 'file.txt'), 'hello')
gitCommit(sourcePath, 'initial')
execFileSync('git', [
'clone',
'--quiet',
`--separate-git-dir=${gitDirPath}`,
sourcePath,
worktreePath
])
const result = (await dispatcher.callRequest('git.listWorktrees', {
repoPath: await fs.realpath(worktreePath)
})) as Record<string, unknown>[]
const mainWorktree = result.find((worktree) => worktree.isMainWorktree === true)
expect(mainWorktree).toMatchObject({
path: await fs.realpath(worktreePath),
isMainWorktree: true
})
expect(mainWorktree?.path).not.toBe(await fs.realpath(gitDirPath))
}
)
it.skipIf(process.platform === 'win32')(
'leaves an ordinary repo reached via a symlinked path unchanged',
async () => {
// A symlink alias defeats the path-string gate (git reports the
// realpath toplevel); the git-common-dir gate must still skip the
// rewrite for an ordinary repo so the reported path is untouched.
const repoPath = path.join(tmpDir, 'plain-repo')
mkdirSync(repoPath)
gitInit(repoPath)
writeFileSync(path.join(repoPath, 'file.txt'), 'hello')
gitCommit(repoPath, 'initial')
const linkedRepoPath = path.join(tmpDir, 'linked-repo')
symlinkSync(repoPath, linkedRepoPath)
const result = (await dispatcher.callRequest('git.listWorktrees', {
repoPath: linkedRepoPath
})) as Record<string, unknown>[]
const mainWorktree = result.find((worktree) => worktree.isMainWorktree === true)
expect(mainWorktree).toMatchObject({
path: await fs.realpath(repoPath),
isMainWorktree: true
})
}
)
it.skipIf(process.platform === 'win32')(
'leaves the main entry unchanged when scanned via a linked worktree',
async () => {
// A linked worktree has a `.git` pointer file like a separate-git-dir
// checkout, but its porcelain main entry is the real main working root.
// The git-common-dir gate must skip the rewrite so the main entry is
// not overwritten with the linked worktree's own toplevel.
const repoPath = path.join(tmpDir, 'main-repo')
mkdirSync(repoPath)
gitInit(repoPath)
writeFileSync(path.join(repoPath, 'file.txt'), 'hello')
gitCommit(repoPath, 'initial')
const linkedWorktreePath = path.join(tmpDir, 'linked-wt')
execFileSync('git', ['worktree', 'add', '--quiet', linkedWorktreePath, '-b', 'feature'], {
cwd: repoPath,
stdio: 'pipe'
})
const resolvedLinked = await fs.realpath(linkedWorktreePath)
const result = (await dispatcher.callRequest('git.listWorktrees', {
repoPath: resolvedLinked
})) as Record<string, unknown>[]
const mainWorktree = result.find((worktree) => worktree.isMainWorktree === true)
expect(mainWorktree).toMatchObject({
path: await fs.realpath(repoPath),
isMainWorktree: true
})
expect(mainWorktree?.path).not.toBe(resolvedLinked)
}
)
it.skipIf(process.platform === 'win32')(
'lists worktrees whose paths contain newlines',
async () => {
+120 -2
View File
@@ -20,6 +20,7 @@ import {
} from './git-handler-ops'
import { commitCompare as commitCompareOp, commitDiffEntry } from './git-handler-commit-diff-ops'
import {
areRelayWorktreePathsEqual,
commitChangesRelay,
addWorktreeOp,
removeWorktreeOp,
@@ -52,6 +53,64 @@ const execFileAsync = promisify(execFile)
const MAX_GIT_BUFFER = 10 * 1024 * 1024
const BULK_CHUNK_SIZE = 100
function getErrorText(error: unknown): string {
if (typeof error === 'object' && error !== null) {
const parts: string[] = []
if ('message' in error && typeof error.message === 'string') {
parts.push(error.message)
}
if ('stderr' in error && typeof error.stderr === 'string') {
parts.push(error.stderr)
}
return parts.join('\n')
}
return String(error)
}
function isUnsupportedRevParsePathFormatError(error: unknown): boolean {
return /(?:unknown|invalid|unrecognized).*(?:--path-format|path-format)/i.test(
getErrorText(error)
)
}
function isWindowsAbsolutePath(value: string): boolean {
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\')
}
function resolveRelayPath(repoPath: string, value: string): string {
if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) {
return value
}
// Old git ignores `--path-format=absolute`, so a relative toplevel/git-dir
// must be resolved against the scanned repo path. Mirror worktree.ts's
// resolveRevParsePath: pick the win32/posix resolver from the repoPath shape.
return isWindowsAbsolutePath(repoPath)
? path.win32.resolve(repoPath, value)
: path.posix.resolve(repoPath, value)
}
type RelayRepoLocation = { topLevel: string; commonDir: string }
function parseRelayRepoLocation(repoPath: string, output: string): RelayRepoLocation | undefined {
// Old git (pre `--path-format`) echoes the unrecognized flag to stdout and
// exits 0 rather than erroring, so drop any echoed `-`-prefixed lines and
// read the two trailing path lines (toplevel, then git-common-dir). Strip only
// the trailing CR, not surrounding spaces — git paths may legitimately start
// or end with a space.
const lines = output
.split('\n')
.map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line))
.filter((line) => line.length > 0 && !line.startsWith('-'))
if (lines.length < 2) {
return undefined
}
const [topLevel, commonDir] = lines.slice(-2)
return {
topLevel: resolveRelayPath(repoPath, topLevel),
commonDir: resolveRelayPath(repoPath, commonDir)
}
}
function execFileWithStdin(
command: string,
args: string[],
@@ -998,11 +1057,70 @@ export class GitHandler {
}
}
private async readRepoLocation(repoPath: string): Promise<RelayRepoLocation | undefined> {
try {
const { stdout } = await this.git(
['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
repoPath
)
return parseRelayRepoLocation(repoPath, stdout)
} catch (error) {
if (!isUnsupportedRevParsePathFormatError(error)) {
return undefined
}
}
try {
const { stdout } = await this.git(
['rev-parse', '--show-toplevel', '--git-common-dir'],
repoPath
)
return parseRelayRepoLocation(repoPath, stdout)
} catch {
return undefined
}
}
private async normalizeMainWorktreePath(
repoPath: string,
worktrees: Record<string, unknown>[]
): Promise<Record<string, unknown>[]> {
const mainIndex = worktrees.findIndex((worktree) => worktree.isMainWorktree === true)
const mainWorktree = worktrees[mainIndex]
const mainPath = typeof mainWorktree?.path === 'string' ? mainWorktree.path : ''
// Expand `~` so the early-return matches git's absolute porcelain path for
// legacy SSH repos stored with a tilde, sparing them a rev-parse per poll.
const resolvedRepoPath = expandTilde(repoPath)
if (!mainPath || areRelayWorktreePathsEqual(mainPath, resolvedRepoPath)) {
return worktrees
}
const location = await this.readRepoLocation(resolvedRepoPath)
if (!location) {
return worktrees
}
// Why: only a separate-git-dir/submodule main worktree reports the Git
// directory as the main entry — i.e. the main entry equals git-common-dir.
// A linked worktree's main entry is a real working root, so gating on this
// equality avoids overwriting it with the linked worktree's own toplevel.
if (!areRelayWorktreePathsEqual(mainPath, location.commonDir)) {
return worktrees
}
const normalized = [...worktrees]
normalized[mainIndex] = { ...mainWorktree, path: location.topLevel }
return normalized
}
private async listWorktrees(params: Record<string, unknown>) {
const repoPath = params.repoPath as string
try {
const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath)
return parseWorktreeList(stdout, { nulDelimited: true })
return this.normalizeMainWorktreePath(
repoPath,
parseWorktreeList(stdout, { nulDelimited: true })
)
} catch (error) {
if (!isUnsupportedWorktreeListZError(error)) {
return []
@@ -1013,7 +1131,7 @@ export class GitHandler {
// Git rejects it. Fall back to the original line-block parser there.
try {
const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath)
return parseWorktreeList(stdout)
return this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout))
} catch {
return []
}
+275 -2
View File
@@ -4,6 +4,10 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types'
import {
resolveSetupAgentSequenceLaunchCommand,
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV
} from '../shared/setup-agent-sequencing'
const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({
mockPtySpawn: vi.fn(),
@@ -165,8 +169,34 @@ describe('PtyHandler', () => {
expect(handler.activePtyCount).toBe(1)
})
it('keeps SSH spawn commands as hints unless provider delivery is requested', async () => {
await dispatcher.callRequest('pty.spawn', { command: 'echo renderer-owned' })
vi.advanceTimersByTime(50)
const term = mockPtySpawn.mock.results[0]?.value
expect(term.write).not.toHaveBeenCalled()
})
it('submits provider-delivered spawn commands to the relay shell', async () => {
await dispatcher.callRequest('pty.spawn', {
command: 'echo provider-owned',
commandDelivery: 'provider'
})
vi.advanceTimersByTime(49)
const term = mockPtySpawn.mock.results[0]?.value
expect(handler.retainedStartupCommandCount).toBe(1)
expect(term.write).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
const submit = process.platform === 'win32' ? '\r' : '\n'
expect(term.write).toHaveBeenCalledWith(`echo provider-owned${submit}`)
expect(handler.retainedStartupCommandCount).toBe(0)
})
it.skipIf(process.platform === 'win32')(
'enables shell-ready marker env for delivery-hinted startup commands',
'emits shell-ready markers for renderer-delivered startup commands',
async () => {
const oldShell = process.env.SHELL
const oldHome = process.env.HOME
@@ -177,6 +207,7 @@ describe('PtyHandler', () => {
try {
await dispatcher.callRequest('pty.spawn', {
env: { HOME: homeDir },
command: 'echo renderer-owned',
startupCommandDelivery: 'shell-ready'
})
} finally {
@@ -197,11 +228,12 @@ describe('PtyHandler', () => {
| { env?: Record<string, string> }
| undefined
expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1')
expect(handler.retainedStartupCommandCount).toBe(0)
}
)
it.skipIf(process.platform === 'win32')(
'enables shell-ready marker env for Codex native prefill commands',
'emits shell-ready markers for renderer-delivered Codex native prefill commands',
async () => {
const oldShell = process.env.SHELL
const oldHome = process.env.HOME
@@ -232,6 +264,197 @@ describe('PtyHandler', () => {
| { env?: Record<string, string> }
| undefined
expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1')
expect(handler.retainedStartupCommandCount).toBe(0)
}
)
it.skipIf(process.platform === 'win32')(
'enables shell-ready marker env for provider-delivered startup commands',
async () => {
const oldShell = process.env.SHELL
const oldHome = process.env.HOME
const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-ready-env-'))
process.env.SHELL = '/bin/bash'
process.env.HOME = homeDir
try {
await dispatcher.callRequest('pty.spawn', {
env: { HOME: homeDir },
command: 'echo provider-owned',
commandDelivery: 'provider',
startupCommandDelivery: 'shell-ready'
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
if (oldHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = oldHome
}
rmSync(homeDir, { recursive: true, force: true })
}
const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as
| { env?: Record<string, string> }
| undefined
expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1')
}
)
it.skipIf(process.platform === 'win32')(
'uses the sequenced startup command hint for provider shell-ready detection',
async () => {
const oldShell = process.env.SHELL
const oldHome = process.env.HOME
const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-sequenced-ready-env-'))
process.env.SHELL = '/bin/bash'
process.env.HOME = homeDir
try {
await dispatcher.callRequest('pty.spawn', {
env: {
HOME: homeDir,
[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex --prefill 'linked issue context'"
},
command: 'bash -lc wait-for-setup-wrapper',
commandDelivery: 'provider'
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
if (oldHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = oldHome
}
rmSync(homeDir, { recursive: true, force: true })
}
const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as
| { env?: Record<string, string> }
| undefined
expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1')
}
)
it.skipIf(process.platform === 'win32')(
'waits for the shell-ready marker before provider-delivered startup commands',
async () => {
let dataCallback: ((data: string) => void) | undefined
const term = {
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
}
mockPtySpawn.mockReturnValue(term)
const oldShell = process.env.SHELL
const oldHome = process.env.HOME
const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-ready-spawn-'))
process.env.SHELL = '/bin/bash'
process.env.HOME = homeDir
try {
await dispatcher.callRequest('pty.spawn', {
env: { HOME: homeDir },
command: 'echo after-ready',
commandDelivery: 'provider',
startupCommandDelivery: 'shell-ready'
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
if (oldHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = oldHome
}
rmSync(homeDir, { recursive: true, force: true })
}
vi.advanceTimersByTime(1499)
expect(term.write).not.toHaveBeenCalled()
dataCallback?.('\x1b]777;orca-shell-ready\x07user@remote $ ')
vi.advanceTimersByTime(49)
expect(term.write).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(term.write).toHaveBeenCalledWith('echo after-ready\n')
expect(handler.retainedStartupCommandCount).toBe(0)
vi.advanceTimersByTime(8)
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: 'pty-1',
data: 'user@remote $ '
})
}
)
it.skipIf(process.platform === 'win32')(
'flushes held shell-ready marker bytes when provider delivery falls back',
async () => {
let dataCallback: ((data: string) => void) | undefined
const term = {
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
}
mockPtySpawn.mockReturnValue(term)
const oldShell = process.env.SHELL
const oldHome = process.env.HOME
const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-fallback-spawn-'))
process.env.SHELL = '/bin/bash'
process.env.HOME = homeDir
try {
await dispatcher.callRequest('pty.spawn', {
env: { HOME: homeDir },
command: 'echo fallback',
commandDelivery: 'provider',
startupCommandDelivery: 'shell-ready'
})
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
if (oldHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = oldHome
}
rmSync(homeDir, { recursive: true, force: true })
}
dataCallback?.('\x1b]777;orca-shell-ready')
vi.advanceTimersByTime(1500)
expect(term.write).toHaveBeenCalledWith('echo fallback\n')
vi.advanceTimersByTime(8)
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: 'pty-1',
data: '\x1b]777;orca-shell-ready'
})
const result = await dispatcher.callRequest('pty.attach', {
id: 'pty-1',
suppressReplayNotification: true
})
expect(result).toEqual({ replay: '\x1b]777;orca-shell-ready' })
}
)
@@ -252,6 +475,42 @@ describe('PtyHandler', () => {
expect(killSpy).toHaveBeenCalledWith('SIGKILL')
})
it('does not submit provider-delivered commands for stale spawn responses', async () => {
const killSpy = vi.fn()
const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() }
mockPtySpawn.mockReturnValue(term)
await dispatcher.callRequest(
'pty.spawn',
{ command: 'echo stale', commandDelivery: 'provider' },
{ isStale: () => true }
)
vi.advanceTimersByTime(50)
expect(term.write).not.toHaveBeenCalled()
expect(handler.retainedStartupCommandCount).toBe(0)
expect(killSpy).toHaveBeenCalledWith('SIGTERM')
})
it('releases pending provider-delivered commands on shutdown before delivery', async () => {
const killSpy = vi.fn()
const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() }
mockPtySpawn.mockReturnValue(term)
await dispatcher.callRequest('pty.spawn', {
command: 'echo stop-before-run',
commandDelivery: 'provider'
})
expect(handler.retainedStartupCommandCount).toBe(1)
await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
vi.advanceTimersByTime(50)
expect(handler.retainedStartupCommandCount).toBe(0)
expect(term.write).not.toHaveBeenCalled()
expect(killSpy).toHaveBeenCalledWith('SIGKILL')
})
it('increments PTY ids on each spawn', async () => {
const r1 = await dispatcher.callRequest('pty.spawn', {})
const r2 = await dispatcher.callRequest('pty.spawn', {})
@@ -759,6 +1018,20 @@ describe('PtyHandler', () => {
expect(spawnEnv.env.SEEN_PI_CODING_AGENT_DIR).toBe('/remote/pi')
})
it('lets relay env augmenters resolve the original sequenced startup command hint', async () => {
handler.addEnvAugmenter((ctx) => ({
SEEN_LAUNCH_COMMAND_HINT: resolveSetupAgentSequenceLaunchCommand(ctx.env, ctx.command) ?? ''
}))
await dispatcher.callRequest('pty.spawn', {
command: 'powershell wait-wrapper',
env: { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume' }
})
const spawnEnv = mockPtySpawn.mock.calls[0][2] as { env: Record<string, string> }
expect(spawnEnv.env.SEEN_LAUNCH_COMMAND_HINT).toBe('omp --resume')
})
it.skipIf(process.platform === 'win32')(
'wraps bash spawns to restore overlay env after remote startup files',
async () => {
+135 -14
View File
@@ -13,6 +13,13 @@ import {
import { getRelayShellLaunchConfig } from './pty-shell-launch'
import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types'
import { shouldUseShellReadyStartupDelivery } from '../shared/codex-startup-delivery'
import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing'
import {
createShellReadyScanState,
drainShellReadyHeldBytes,
scanForShellReady,
type ShellReadyScanState
} from '../main/shell-ready-marker-scanner'
// Why: node-pty is a native addon that may not be installed on the remote.
// Dynamic import keeps the require() lazy so loadPty() returns null gracefully
@@ -53,12 +60,21 @@ type ManagedPty = {
paneKey?: string
tabId?: string
worktreeId?: string
startupCommand?: ManagedStartupCommand
}
type PendingPtyOutput = {
data: string
}
type ManagedStartupCommand = {
command: string
delivered: boolean
waitForShellReady: boolean
scanState: ShellReadyScanState | null
timer: ReturnType<typeof setTimeout> | null
}
function disposeManagedPty(managed: ManagedPty): void {
if (managed.disposed) {
return
@@ -98,6 +114,8 @@ const INTERACTIVE_OUTPUT_WINDOW_MS = 100
const INTERACTIVE_OUTPUT_MAX_CHARS = 1024
const INTERACTIVE_REDRAW_MAX_CHARS = PTY_OUTPUT_FLUSH_CHUNK_CHARS
const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024
const STARTUP_COMMAND_WRITE_DELAY_MS = 50
const STARTUP_COMMAND_SHELL_READY_FALLBACK_MS = 1500
const ALLOWED_SIGNALS = new Set([
'SIGINT',
'SIGTERM',
@@ -222,14 +240,71 @@ export class PtyHandler {
return { ...baseEnv, ...augmented }
}
private clearStartupCommandTimer(managed: ManagedPty): void {
if (managed.startupCommand?.timer) {
clearTimeout(managed.startupCommand.timer)
managed.startupCommand.timer = null
}
}
private appendReplayBuffer(managed: ManagedPty, data: string): void {
managed.buffered += data
if (managed.buffered.length > REPLAY_BUFFER_MAX) {
managed.buffered = managed.buffered.slice(-REPLAY_BUFFER_MAX)
}
}
private releaseStartupCommand(managed: ManagedPty): void {
this.clearStartupCommandTimer(managed)
managed.startupCommand = undefined
}
private scheduleStartupCommandDelivery(managed: ManagedPty, delayMs: number): void {
const startup = managed.startupCommand
if (!startup || startup.delivered || managed.disposed) {
return
}
this.clearStartupCommandTimer(managed)
startup.timer = setTimeout(() => {
startup.timer = null
this.deliverStartupCommand(managed)
}, delayMs)
}
private deliverStartupCommand(managed: ManagedPty): void {
const startup = managed.startupCommand
if (!startup || startup.delivered || managed.disposed) {
return
}
startup.delivered = true
this.clearStartupCommandTimer(managed)
if (startup.scanState) {
const heldBytes = drainShellReadyHeldBytes(startup.scanState)
if (heldBytes) {
this.appendReplayBuffer(managed, heldBytes)
this.enqueuePtyOutput(managed.id, heldBytes)
}
}
const submit = process.platform === 'win32' ? '\r' : '\n'
const endsWithSubmit = startup.command.endsWith('\r') || startup.command.endsWith('\n')
const payload = endsWithSubmit ? startup.command : `${startup.command}${submit}`
managed.startupCommand = undefined
managed.pty.write(payload)
}
/** Wire onData/onExit listeners for a managed PTY and store it. */
private wireAndStore(managed: ManagedPty): void {
this.ptys.set(managed.id, managed)
managed.pty.onData((data: string) => {
managed.buffered += data
if (managed.buffered.length > REPLAY_BUFFER_MAX) {
managed.buffered = managed.buffered.slice(-REPLAY_BUFFER_MAX)
const startup = managed.startupCommand
if (startup?.waitForShellReady && startup.scanState && !startup.delivered) {
const scanned = scanForShellReady(startup.scanState, data)
data = scanned.output
if (scanned.matched) {
this.scheduleStartupCommandDelivery(managed, STARTUP_COMMAND_WRITE_DELAY_MS)
}
}
this.appendReplayBuffer(managed, data)
this.enqueuePtyOutput(managed.id, data)
})
managed.pty.onExit(({ exitCode }: { exitCode: number }) => {
@@ -253,6 +328,7 @@ export class PtyHandler {
clearTimeout(managed.killTimer)
managed.killTimer = undefined
}
this.clearStartupCommandTimer(managed)
this.flushPtyOutput(managed.id)
this.dispatcher.notify('pty.exit', { id: managed.id, code: exitCode })
this.notifyExitListener(managed)
@@ -428,20 +504,26 @@ export class PtyHandler {
// dirs) override renderer-supplied env so live remote paths and hook coords
// win over local userData paths. The context lets overlay augmenters derive
// per-PTY OpenCode/Pi directories from the stable paneKey when present.
// `command` is forwarded by ssh-pty-provider.ts only as a hint for
// overlay resolution — the relay still launches a login shell and the
// command is typed in via pty.data writes.
// `command` is usually forwarded by ssh-pty-provider.ts only as a hint
// for overlay resolution; runtime-owned PTYs opt into relay delivery
// because no renderer TerminalPane exists to type the command.
const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined
const command = typeof params.command === 'string' ? params.command : undefined
const commandDelivery = params.commandDelivery === 'provider' ? 'provider' : 'renderer'
const shouldProviderDeliverCommand = commandDelivery === 'provider' && command !== undefined
const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell, command })
// Why: only explicit shell-ready hints are trusted here; native Codex
// prefill detection still auto-enables readiness through the predicate.
const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, {
emitReadyMarker: shouldUseShellReadyStartupDelivery({
command,
const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(spawnEnv, command)
const shouldEmitShellReadyMarker =
launchCommandHint !== undefined &&
shouldUseShellReadyStartupDelivery({
command: launchCommandHint,
startupCommandDelivery:
params.startupCommandDelivery === 'shell-ready' ? 'shell-ready' : undefined
})
// Why: renderer- and provider-delivered startup commands both use this
// marker; the side responsible for delivery also strips it from output.
const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, {
emitReadyMarker: shouldEmitShellReadyMarker
})
// Why: SSH exec channels give the relay a minimal environment without
@@ -454,7 +536,9 @@ export class PtyHandler {
cols,
rows,
cwd,
env: { ...spawnEnv, ...shellLaunch.env }
// Why: relay shells inherit process.env; never let an ambient Orca marker
// enable shell-ready behavior unless this spawn explicitly requested it.
env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env }
})
// Why: capture the renderer-supplied paneKey on the managed entry so the
@@ -470,13 +554,28 @@ export class PtyHandler {
buffered: '',
paneKey,
tabId,
worktreeId
worktreeId,
...(shouldProviderDeliverCommand
? {
startupCommand: {
command,
delivered: false,
waitForShellReady: shellLaunch.env.ORCA_SHELL_READY_MARKER === '1',
scanState:
shellLaunch.env.ORCA_SHELL_READY_MARKER === '1'
? createShellReadyScanState()
: null,
timer: null
}
}
: {})
}
this.wireAndStore(managed)
if (context?.isStale()) {
// Why: if the client reconnected while pty.spawn was in flight, the
// response is discarded and no renderer can own this PTY. Shut it down
// immediately so it does not linger as an unreachable remote shell.
this.releaseStartupCommand(managed)
term.kill('SIGTERM')
managed.killTimer = setTimeout(() => {
const still = this.ptys.get(id)
@@ -491,6 +590,13 @@ export class PtyHandler {
this.ptys.delete(id)
}
}, 5000)
} else if (managed.startupCommand) {
this.scheduleStartupCommandDelivery(
managed,
managed.startupCommand.waitForShellReady
? STARTUP_COMMAND_SHELL_READY_FALLBACK_MS
: STARTUP_COMMAND_WRITE_DELAY_MS
)
}
return { id }
}
@@ -562,6 +668,7 @@ export class PtyHandler {
}
if (immediate) {
this.releaseStartupCommand(managed)
this.flushPtyOutput(id)
managed.pty.kill('SIGKILL')
// Why: SIGKILL has already reaped the child; release the ptmx fd on the
@@ -581,6 +688,7 @@ export class PtyHandler {
this.ptys.delete(id)
this.clearPtyFlowState(id)
} else {
this.releaseStartupCommand(managed)
managed.pty.kill('SIGTERM')
// Why: Some processes ignore SIGTERM (e.g. a hung child, a custom signal
@@ -761,7 +869,9 @@ export class PtyHandler {
cols: entry.cols,
rows: entry.rows,
cwd: entry.cwd,
env: { ...spawnEnv, ...shellLaunch.env }
// Why: revived shells should not inherit an ambient shell-ready marker
// because no provider-delivered startup command is waiting on it.
env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env }
})
this.wireAndStore({
id: entry.id,
@@ -821,6 +931,7 @@ export class PtyHandler {
clearTimeout(managed.killTimer)
managed.killTimer = undefined
}
this.clearStartupCommandTimer(managed)
// Why: SIGKILL (not SIGTERM) before destroy. The relay process is
// exiting; any SIGTERM-ignoring remote shell (editor with unsaved
// buffers, a hung child with a bad handler, a process in
@@ -843,6 +954,16 @@ export class PtyHandler {
return this.ptys.size
}
get retainedStartupCommandCount(): number {
let count = 0
for (const managed of this.ptys.values()) {
if (managed.startupCommand) {
count += 1
}
}
return count
}
get graceTimerActive(): boolean {
return this.graceTimer !== null
}
+5 -2
View File
@@ -57,6 +57,7 @@ import {
import { assertPluginSourceUnderByteCap } from './plugin-source-limit'
import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env'
import { detectPiAgentKindFromCommand } from '../shared/pi-agent-kind'
import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing'
import { pickRemoteCliEnv } from './remote-cli-env'
import { remoteCliRequestTimeoutMs } from './remote-cli-timeout'
import { shouldReadRemoteCliStdin } from './remote-cli-stdin'
@@ -536,8 +537,10 @@ async function main(): Promise<void> {
// Why: source-dir defaulting is keyed on which Pi-compatible agent is
// being launched (Pi vs OMP). Install Orca's guarded extension into that
// real remote agent dir without redirecting PI_CODING_AGENT_DIR.
const kind = detectPiAgentKindFromCommand(ctx.command)
const hasLaunchCommand = typeof ctx.command === 'string' && ctx.command.trim().length > 0
const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(ctx.env, ctx.command)
const kind = detectPiAgentKindFromCommand(launchCommandHint)
const hasLaunchCommand =
typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0
const shouldPrepareOmpShadow = kind === 'omp' || !hasLaunchCommand
if (kind === 'pi') {
const sourceDir = resolvePiSourceAgentDir(ctx.env, ctx.shell, 'pi')
@@ -150,6 +150,8 @@ function renderCard(
requiresExplicitSetupChoice={false}
setupDecision={null}
onSetupDecisionChange={() => {}}
setupAgentStartupPolicy="start-immediately"
onSetupAgentStartupPolicyChange={() => {}}
shouldWaitForSetupCheck={false}
resolvedSetupDecision={null}
createError={null}
@@ -266,6 +268,58 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
expect(onChanges).toEqual([true])
})
it('shows the setup startup policy toggle only when setup is available', () => {
current = renderCard({
advancedOpen: true,
setupControlsEnabled: true,
setupConfig: {
source: 'yaml',
command: '# defaultTabs[1]\npnpm dev',
kind: 'default-tabs'
}
})
expect(current.container.textContent).not.toContain(
'Wait for setup to complete before starting agent'
)
act(() => current?.root.unmount())
current?.container.remove()
current = renderCard({
advancedOpen: true,
setupControlsEnabled: true,
setupConfig: {
source: 'yaml',
command: 'pnpm install',
kind: 'setup'
}
})
expect(current.container.textContent).toContain(
'Wait for setup to complete before starting agent'
)
})
it('emits the setup startup policy toggle value', () => {
const changes: string[] = []
current = renderCard({
advancedOpen: true,
setupControlsEnabled: true,
setupConfig: {
source: 'yaml',
command: 'pnpm install',
kind: 'setup'
},
onSetupAgentStartupPolicyChange: (next) => changes.push(next)
})
const waitSwitch = current.container.querySelector<HTMLElement>(
'[role="switch"][aria-label="Wait for setup to complete before starting agent"]'
)
expect(waitSwitch).toBeTruthy()
act(() => waitSwitch?.click())
expect(changes).toEqual(['wait-for-setup'])
})
it('does not disable folder workspace creation when only source lookup needs SSH', () => {
current = renderCard({
eligibleRepos: [
@@ -16,6 +16,7 @@ import {
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { SettingsSwitch } from '@/components/settings/SettingsFormControls'
import type RepoCombobox from '@/components/repo/RepoCombobox'
import AgentCombobox from '@/components/agent/AgentCombobox'
import { getAgentCatalog } from '@/lib/agent-catalog'
@@ -35,6 +36,7 @@ import type {
GitHubWorkItem,
GitLabWorkItem,
LinearIssue,
SetupAgentStartupPolicy,
SparsePreset,
TuiAgent
} from '../../../shared/types'
@@ -116,6 +118,8 @@ type NewWorkspaceComposerCardProps = {
requiresExplicitSetupChoice: boolean
setupDecision: 'run' | 'skip' | null
onSetupDecisionChange: (value: 'run' | 'skip') => void
setupAgentStartupPolicy: SetupAgentStartupPolicy
onSetupAgentStartupPolicyChange: (value: SetupAgentStartupPolicy) => void
shouldWaitForSetupCheck: boolean
resolvedSetupDecision: 'run' | 'skip' | null
createError: WorkspaceCreateErrorDisplay | null
@@ -354,6 +358,8 @@ export default function NewWorkspaceComposerCard({
requiresExplicitSetupChoice,
setupDecision,
onSetupDecisionChange,
setupAgentStartupPolicy,
onSetupAgentStartupPolicyChange,
shouldWaitForSetupCheck,
resolvedSetupDecision,
createError,
@@ -421,6 +427,10 @@ export default function NewWorkspaceComposerCard({
? 'Run commands now'
: 'Run setup now'
const setupSkipButtonLabel = setupConfig?.kind === 'setup' ? 'Skip for now' : 'Skip commands'
// Why: defaultTabs launch commands can be long-running too, but they are not
// the setup command this setting gates agent startup on.
const showSetupAgentStartupPolicy =
setupControlsEnabled && setupConfig !== null && setupConfig.kind !== 'default-tabs'
const handleSetDefaultAgent = React.useCallback(
(next: TuiAgent | 'blank' | null) => {
@@ -1004,6 +1014,39 @@ export default function NewWorkspaceComposerCard({
) : null}
</div>
) : null}
{showSetupAgentStartupPolicy ? (
<div className="flex items-start justify-between gap-3 rounded-md border border-border/60 bg-muted/25 p-3">
<span className="min-w-0 space-y-1">
<span className="block text-xs font-medium text-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgent',
'Wait for setup to complete before starting agent'
)}
</span>
<span className="block text-[11px] text-muted-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgentHelp',
'Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup.'
)}
</span>
</span>
<SettingsSwitch
checked={setupAgentStartupPolicy === 'wait-for-setup'}
onChange={() =>
onSetupAgentStartupPolicyChange(
setupAgentStartupPolicy === 'wait-for-setup'
? 'start-immediately'
: 'wait-for-setup'
)
}
ariaLabel={translate(
'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgent',
'Wait for setup to complete before starting agent'
)}
/>
</div>
) : null}
</div>
) : null}
@@ -17,6 +17,7 @@ import { useRepoById } from '@/store/selectors'
import { resolveRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id'
import {
AI_VAULT_SESSION_DRAG_END_EVENT,
AI_VAULT_SESSION_DRAG_START_EVENT,
writeAiVaultSessionDragData
} from '@/lib/ai-vault-session-drag'
@@ -82,8 +83,13 @@ export function VaultSessionRow({
? translate('auto.components.right.sidebar.AiVaultSessionRow.hideDetails', 'Hide Details')
: translate('auto.components.right.sidebar.AiVaultSessionRow.showDetails', 'Show Details')
const startResumeDrag = useCallback(
(event: React.DragEvent<HTMLButtonElement>): void => {
(event: React.DragEvent<HTMLElement>): void => {
event.stopPropagation()
const target = event.target
if (target instanceof Element && target.closest('[data-ai-vault-session-actions]')) {
event.preventDefault()
return
}
if (resumeDisabled) {
event.preventDefault()
return
@@ -106,12 +112,20 @@ export function VaultSessionRow({
<ContextMenuTrigger asChild className="block w-full min-w-0">
<div
className={cn(
'group/session-row flex w-full min-w-0 cursor-pointer flex-col border-b border-sidebar-border px-3 py-2 text-left transition-colors hover:bg-sidebar-accent/55',
'group/session-row flex w-full min-w-0 flex-col border-b border-sidebar-border px-3 py-2 text-left transition-colors hover:bg-sidebar-accent/55',
resumeDisabled ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing',
!detailsExpanded && 'min-h-[98px]'
)}
// Why: users naturally drag the session row itself; matching that
// gesture avoids hidden affordances and text-selection false starts.
draggable={!resumeDisabled}
onClick={() => {
onToggleDetails()
}}
onDragStart={startResumeDrag}
onDragEnd={() => {
window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_END_EVENT))
}}
>
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-1">
<div
@@ -140,7 +154,6 @@ export function VaultSessionRow({
onOpenLog={onOpenLog}
onRevealLog={onRevealLog}
onOpenCwd={onOpenCwd}
onStartResumeDrag={startResumeDrag}
/>
</div>
{detailsExpanded && worktreeInfo ? (
@@ -110,6 +110,8 @@ import {
buildChecksPanelGitStatusContextKey,
readChecksPanelPublishActionGitStatus,
readChecksPanelGitStatusSnapshot,
readChecksPanelRefreshGitIdentitySnapshot,
hasChecksPanelGitStatusBranchChanged,
shouldClearChecksPanelGitStatusSnapshot,
shouldCoalesceChecksPanelGitStatusSnapshotRefresh,
shouldCommitChecksPanelGitStatusSnapshot,
@@ -397,6 +399,7 @@ export default function ChecksPanel(): React.JSX.Element {
const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen)
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const updateWorktreeGitIdentity = useAppStore((s) => s.updateWorktreeGitIdentity)
const openModal = useAppStore((s) => s.openModal)
// Why: the sidebar stays mounted when closed (for performance). Gate
@@ -426,6 +429,7 @@ export default function ChecksPanel(): React.JSX.Element {
const commentsRef = useRef<PRComment[]>([])
const [emptyRefreshing, setEmptyRefreshing] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const refreshInFlightRef = useRef(false)
const [conflictDetailsRefreshing, setConflictDetailsRefreshing] = useState(false)
const createPrInFlightRef = useRef<string | null>(null)
const [isCreatingPr, setIsCreatingPr] = useState(false)
@@ -590,6 +594,7 @@ export default function ChecksPanel(): React.JSX.Element {
pollIntervalRef.current = 30_000
prevChecksRef.current = ''
conflictSummaryRefreshKeyRef.current = null
refreshInFlightRef.current = false
refreshRequestKeyRef.current = null
if (gitStatusSnapshotRetryTimerRef.current) {
clearTimeout(gitStatusSnapshotRetryTimerRef.current)
@@ -1303,6 +1308,17 @@ export default function ChecksPanel(): React.JSX.Element {
}
void (async () => {
const status = await getRuntimeGitStatus(context)
if (
!stale &&
shouldCommitChecksPanelGitStatusSnapshot(panelContextKeyRef.current, requestContextKey)
) {
// Why: the Checks tab can be the only visible git surface; commit
// branch identity before branch-scoped upstream refresh can fail.
updateWorktreeGitIdentity(activeWorktreeId, {
head: status.head,
branch: status.branch ?? (status.head ? null : undefined)
})
}
let freshRemoteStatus = status.upstreamStatus
if (activeWorktreePushTarget) {
freshRemoteStatus = await getRuntimeGitUpstreamStatus(context, activeWorktreePushTarget)
@@ -1324,7 +1340,11 @@ export default function ChecksPanel(): React.JSX.Element {
setGitStatusSnapshot({
contextKey: requestContextKey,
hasUncommittedChanges: status.entries.length > 0,
remoteStatus
remoteStatus,
gitIdentity: {
head: status.head,
branch: status.branch ?? (status.head ? null : undefined)
}
})
}
})
@@ -1385,7 +1405,8 @@ export default function ChecksPanel(): React.JSX.Element {
repoConnectionId,
remoteStatusInvalidation,
runtimeEnvironmentId,
sshConnectionStatus
sshConnectionStatus,
updateWorktreeGitIdentity
])
useEffect(() => {
@@ -1839,6 +1860,12 @@ export default function ChecksPanel(): React.JSX.Element {
if (!repo || !branch) {
return
}
if (refreshInFlightRef.current) {
return
}
// Why: React has not disabled the button until the next render, so a rapid
// double-click must not start duplicate git status/upstream subprocesses.
refreshInFlightRef.current = true
const initialRequestKey = checksPanelAsyncResultKey(
prCacheKey,
branch,
@@ -1853,7 +1880,6 @@ export default function ChecksPanel(): React.JSX.Element {
const refreshProvider = isGitLabReviewContext ? 'gitlab' : 'github'
let refreshOutcome = 'started'
setIsRefreshing(true)
setGitStatusRefreshNonce((value) => value + 1)
recordChecksPanelPRRefreshBreadcrumb({
event: 'start',
provider: refreshProvider,
@@ -1867,6 +1893,81 @@ export default function ChecksPanel(): React.JSX.Element {
refreshState: prCacheKey ? useAppStore.getState().prRefreshStates[prCacheKey] : null
})
try {
if (activeWorktreeId && activeWorktreePath && !isFolder) {
const snapshotIdentity = readChecksPanelRefreshGitIdentitySnapshot({
snapshot: gitStatusSnapshot,
contextKey: panelContextKey,
currentBranch: branch
})
if (snapshotIdentity.kind === 'changed') {
updateWorktreeGitIdentity(activeWorktreeId, {
head: snapshotIdentity.head,
branch: snapshotIdentity.branch
})
// Why: this click discovered a terminal branch switch. Let the
// branch-keyed render/effects restart instead of refreshing old PR data.
refreshOutcome = 'branch-changed'
return
}
try {
const statusContext = {
settings: ownerSettings,
worktreeId: activeWorktreeId,
worktreePath: activeWorktreePath,
connectionId: activeConnectionId ?? undefined
}
const status = await getRuntimeGitStatus(statusContext)
const observedBranch = status.branch ?? (status.head ? null : undefined)
updateWorktreeGitIdentity(activeWorktreeId, {
head: status.head,
branch: observedBranch
})
if (
observedBranch !== undefined &&
hasChecksPanelGitStatusBranchChanged({
observedBranch,
currentBranch: branch
})
) {
// Why: this click discovered a terminal branch switch. Let the
// branch-keyed render/effects restart instead of refreshing old PR data.
refreshOutcome = 'branch-changed'
return
}
let freshRemoteStatus = status.upstreamStatus
if (activeWorktreePushTarget) {
freshRemoteStatus = await getRuntimeGitUpstreamStatus(
statusContext,
activeWorktreePushTarget
)
} else if (
!freshRemoteStatus ||
(freshRemoteStatus.ahead > 0 &&
freshRemoteStatus.behind > 0 &&
freshRemoteStatus.behindCommitsArePatchEquivalent === undefined)
) {
freshRemoteStatus = await getRuntimeGitUpstreamStatus(statusContext)
}
if (
isCurrentRequest() &&
shouldCommitChecksPanelGitStatusSnapshot(panelContextKeyRef.current, panelContextKey)
) {
// Why: the explicit Refresh click already paid for this status read;
// commit it so empty-state Publish/Create eligibility is fresh.
setGitStatusSnapshot({
contextKey: panelContextKey,
hasUncommittedChanges: status.entries.length > 0,
remoteStatus: freshRemoteStatus,
gitIdentity: {
head: status.head,
branch: observedBranch
}
})
}
} catch (error) {
console.warn('[ChecksPanel] pre-refresh git identity refresh failed', error)
}
}
if (isGitLabReviewContext) {
const refreshedReview = await refreshHostedReviewCard(fetchHostedReviewForBranch, {
repoPath: repo.path,
@@ -2042,13 +2143,17 @@ export default function ChecksPanel(): React.JSX.Element {
currentRequest: isCurrentRequest()
})
if (isCurrentRequest()) {
refreshInFlightRef.current = false
setIsRefreshing(false)
}
}
}, [
repo,
branch,
activeConnectionId,
activeWorktreeId,
activeWorktreePath,
activeWorktreePushTarget,
activeGitLabReview,
prNumber,
pr?.checksStatus,
@@ -2063,13 +2168,18 @@ export default function ChecksPanel(): React.JSX.Element {
linkedBitbucketPR,
linkedGiteaPR,
linkedGitLabMR,
isFolder,
isGitLabReviewContext,
gitStatusSnapshot,
panelContextKey,
fetchPRForBranch,
fetchPRChecks,
fetchPRComments,
fetchHostedReviewForBranch,
expireGitHubPRRefreshState,
isCurrentAsyncResult
isCurrentAsyncResult,
ownerSettings,
updateWorktreeGitIdentity
])
const handleEntryRefresh = useCallback(
@@ -1,13 +1,5 @@
import { useCallback, useEffect, useRef } from 'react'
import type React from 'react'
import {
ChevronDown,
GripVertical,
LocateFixed,
MoreHorizontal,
PanelTopOpen,
Play
} from 'lucide-react'
import { ChevronDown, LocateFixed, MoreHorizontal, PanelTopOpen, Play } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
@@ -16,7 +8,6 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { AI_VAULT_SESSION_DRAG_END_EVENT } from '@/lib/ai-vault-session-drag'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { agentLabel } from './ai-vault-session-filters'
import { translate } from '@/i18n/i18n'
@@ -58,8 +49,7 @@ export function SessionRowTrailingActions({
onCopyPath,
onOpenLog,
onRevealLog,
onOpenCwd,
onStartResumeDrag
onOpenCwd
}: {
session: AiVaultSession
detailsExpanded: boolean
@@ -78,75 +68,17 @@ export function SessionRowTrailingActions({
onOpenLog: () => void
onRevealLog: () => void
onOpenCwd?: () => void
onStartResumeDrag: (event: React.DragEvent<HTMLButtonElement>) => void
}) {
// Track drag state to cleanup on unmount
const isDraggingRef = useRef(false)
const handleDragStart = useCallback(
(event: React.DragEvent<HTMLButtonElement>) => {
isDraggingRef.current = true
onStartResumeDrag(event)
},
[onStartResumeDrag]
)
const handleDragEnd = useCallback(() => {
isDraggingRef.current = false
window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_END_EVENT))
}, [])
const jumpToWorktreeTooltip = aiVaultWorktreeJumpTooltip(worktreeInfo)
// Cleanup drag state on unmount if a drag was in progress
useEffect(() => {
return () => {
if (isDraggingRef.current) {
window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_END_EVENT))
isDraggingRef.current = false
}
}
}, [])
return (
<div
className="flex shrink-0 items-center gap-1"
data-ai-vault-session-actions="true"
onPointerDown={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
>
<div className={HOVER_ACTION_GROUP_CLASS}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.right.sidebar.AiVaultSessionRow.dragToResume',
'Drag to resume in a new tab'
)}
disabled={resumeDisabled}
draggable={!resumeDisabled}
onClick={(event) => {
event.stopPropagation()
}}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
data-testid="ai-vault-session-drag-handle"
// Why: the card is for inspection. Drag-to-resume lives on a
// quiet handle so the row does not look movable by default.
className="cursor-grab can-hover:pointer-events-none active:cursor-grabbing group-hover/session-row:pointer-events-auto group-focus-within/session-row:pointer-events-auto focus-visible:pointer-events-auto"
>
<GripVertical className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.dragToResume',
'Drag to resume in a new tab'
)}
</TooltipContent>
</Tooltip>
{onJumpToOriginalPane ? (
<Tooltip>
<TooltipTrigger asChild>
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'
import {
buildChecksPanelGitStatusContextKey,
readChecksPanelPublishActionGitStatus,
readChecksPanelRefreshGitIdentitySnapshot,
readChecksPanelGitStatusSnapshot,
hasChecksPanelGitStatusBranchChanged,
shouldClearChecksPanelGitStatusSnapshot,
shouldCoalesceChecksPanelGitStatusSnapshotRefresh,
shouldCommitChecksPanelGitStatusSnapshot,
@@ -17,6 +19,10 @@ const SNAPSHOT: ChecksPanelGitStatusSnapshot = {
hasUpstream: false,
ahead: 0,
behind: 0
},
gitIdentity: {
head: 'abc123',
branch: 'refs/heads/feature/checks'
}
}
@@ -188,6 +194,82 @@ describe('readChecksPanelPublishActionGitStatus', () => {
})
})
describe('readChecksPanelRefreshGitIdentitySnapshot', () => {
it('treats refs/heads display differences as the same branch identity', () => {
expect(
readChecksPanelRefreshGitIdentitySnapshot({
snapshot: SNAPSHOT,
contextKey: SNAPSHOT.contextKey,
currentBranch: 'feature/checks'
})
).toEqual({ kind: 'same' })
})
it('reports missing when the snapshot is from a different execution context', () => {
expect(
readChecksPanelRefreshGitIdentitySnapshot({
snapshot: SNAPSHOT,
contextKey: 'runtime:env-2::repo::worktree::branch',
currentBranch: 'feature/checks'
})
).toEqual({ kind: 'missing' })
})
it('reports changed when the observed branch is a different branch', () => {
expect(
readChecksPanelRefreshGitIdentitySnapshot({
snapshot: {
...SNAPSHOT,
gitIdentity: {
head: 'def456',
branch: 'refs/heads/feature/next'
}
},
contextKey: SNAPSHOT.contextKey,
currentBranch: 'feature/checks'
})
).toEqual({
kind: 'changed',
head: 'def456',
branch: 'refs/heads/feature/next'
})
})
it('reports missing when the snapshot lacks branch/head identity', () => {
expect(
readChecksPanelRefreshGitIdentitySnapshot({
snapshot: {
contextKey: SNAPSHOT.contextKey,
hasUncommittedChanges: false,
remoteStatus: undefined
},
contextKey: SNAPSHOT.contextKey,
currentBranch: 'feature/checks'
})
).toEqual({ kind: 'missing' })
})
})
describe('hasChecksPanelGitStatusBranchChanged', () => {
it('does not treat refs/heads display differences as branch changes', () => {
expect(
hasChecksPanelGitStatusBranchChanged({
observedBranch: 'refs/heads/feature/checks',
currentBranch: 'feature/checks'
})
).toBe(false)
})
it('treats detached HEAD as changed from the rendered branch', () => {
expect(
hasChecksPanelGitStatusBranchChanged({
observedBranch: null,
currentBranch: 'feature/checks'
})
).toBe(true)
})
})
describe('shouldCommitChecksPanelGitStatusSnapshot', () => {
it('suppresses stale status refresh completions from an older execution boundary', () => {
expect(
@@ -19,6 +19,10 @@ export type ChecksPanelGitStatusSnapshot = {
contextKey: string
hasUncommittedChanges: boolean
remoteStatus: GitUpstreamStatus | undefined
gitIdentity?: {
head?: string
branch?: string | null
}
}
export type ChecksPanelGitStatusInputs = {
@@ -26,6 +30,19 @@ export type ChecksPanelGitStatusInputs = {
remoteStatus: GitUpstreamStatus | undefined
}
export type ChecksPanelRefreshGitIdentitySnapshot =
| {
kind: 'missing'
}
| {
kind: 'same'
}
| {
kind: 'changed'
head?: string
branch: string | null
}
export function buildChecksPanelGitStatusContextKey(
input: ChecksPanelGitStatusContextInput
): string {
@@ -118,3 +135,44 @@ export function readChecksPanelPublishActionGitStatus(input: {
remoteStatus: input.fallbackRemoteStatus
}
}
function canonicalBranchIdentity(branch: string | null | undefined): string {
return (branch ?? '').replace(/^refs\/heads\//, '').trim()
}
export function readChecksPanelRefreshGitIdentitySnapshot(input: {
snapshot: ChecksPanelGitStatusSnapshot | null
contextKey: string
currentBranch: string
}): ChecksPanelRefreshGitIdentitySnapshot {
if (
!input.snapshot ||
input.snapshot.contextKey !== input.contextKey ||
!input.snapshot.gitIdentity ||
input.snapshot.gitIdentity.branch === undefined
) {
return { kind: 'missing' }
}
if (
canonicalBranchIdentity(input.snapshot.gitIdentity.branch) ===
canonicalBranchIdentity(input.currentBranch)
) {
return { kind: 'same' }
}
return {
kind: 'changed',
head: input.snapshot.gitIdentity.head,
branch: input.snapshot.gitIdentity.branch
}
}
export function hasChecksPanelGitStatusBranchChanged(input: {
observedBranch: string | null | undefined
currentBranch: string
}): boolean {
return (
canonicalBranchIdentity(input.observedBranch) !== canonicalBranchIdentity(input.currentBranch)
)
}
@@ -0,0 +1,56 @@
import type React from 'react'
import { useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAppStore } from '../../store'
import { normalizeSettingsSearchQuery } from './settings-search'
import { translate } from '@/i18n/i18n'
type AppearanceAdvancedDisclosureProps = {
/** Optional override label; defaults to "Advanced". */
label?: string
showTopBorder?: boolean
className?: string
contentClassName?: string
children: React.ReactNode
}
/** Inline "Advanced" disclosure for low-frequency controls. An active settings
* search force-opens it so matching controls stay reachable instead of being
* hidden behind a collapsed trigger. */
export function AppearanceAdvancedDisclosure({
label,
showTopBorder = true,
className,
contentClassName,
children
}: AppearanceAdvancedDisclosureProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
const [open, setOpen] = useState(false)
const expanded = open || isSearching
return (
<div className={cn('mt-3 pt-2', showTopBorder && 'border-t border-border/50', className)}>
<button
type="button"
aria-expanded={expanded}
onClick={() => setOpen((prev) => !prev)}
// Why: while searching the disclosure is forced open, so disable the
// toggle's collapse affordance rather than letting it fight the search.
disabled={isSearching}
className="flex w-full items-center gap-2 py-1 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-default"
>
<ChevronRight
className={cn(
'size-3.5 text-muted-foreground transition-transform',
expanded && 'rotate-90'
)}
/>
{label ??
translate('auto.components.settings.AppearanceAdvancedDisclosure.advanced', 'Advanced')}
</button>
{expanded ? <div className={cn('pt-1', contentClassName)}>{children}</div> : null}
</div>
)
}
@@ -0,0 +1,241 @@
import type React from 'react'
import type { GlobalSettings } from '../../../../shared/types'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { UIZoomControl } from './UIZoomControl'
import { SearchableSetting } from './SearchableSetting'
import { AppearanceAdvancedDisclosure } from './AppearanceAdvancedDisclosure'
import { useAppStore } from '../../store'
import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
import { ShortcutHintList } from './AppearanceShortcutHintList'
import {
FontAutocomplete,
SettingsRow,
SettingsSegmentedControl,
SettingsSwitchRow
} from './SettingsFormControls'
import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants'
import {
getLanguageEntries,
getSystemTrayEntries,
getThemeEntries,
getTitlebarEntries,
getTypographyEntries,
getZoomEntries
} from './appearance-search'
import {
getUiLanguageChoiceLabel,
SHOW_UI_LANGUAGE_SETTING,
UI_LANGUAGE_CHOICES
} from '@/i18n/supported-languages'
import { translate } from '@/i18n/i18n'
import type { UiLanguage } from '../../../../shared/ui-language'
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search'
type AppearanceInterfaceSectionProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
applyTheme: (theme: 'system' | 'dark' | 'light') => void
fontSuggestions: string[]
isDesktopWindows: boolean
forceVisiblePrimary?: boolean
}
export function AppearanceInterfaceSection({
settings,
updateSettings,
applyTheme,
fontSuggestions,
isDesktopWindows,
forceVisiblePrimary = false
}: AppearanceInterfaceSectionProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
const zoomInKeyCombos = useShortcutKeyComboDetails('zoom.in')
const zoomOutKeyCombos = useShortcutKeyComboDetails('zoom.out')
const languageEntry = getLanguageEntries()[0]
const systemTrayEntry = getSystemTrayEntries({ showSystemTray: true })[0]
const themeEntry = getThemeEntries()[0]
const themeLabel = translate('auto.components.settings.AppearancePane.932ff1fbff', 'Theme')
const titlebarEntry = getTitlebarEntries()[0]
const typographyEntry = getTypographyEntries()[0]
const zoomEntry = getZoomEntries()[0]
const advancedEntries = [
...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []),
...getTitlebarEntries(),
...getSystemTrayEntries({ showSystemTray: isDesktopWindows })
]
const showAdvanced = !isSearching || matchesSettingsSearch(searchQuery, advancedEntries)
return (
<div className="divide-y divide-border/40">
<SearchableSetting
title={themeLabel}
description={themeEntry?.description}
keywords={themeEntry?.keywords ?? ['dark', 'light', 'system']}
forceVisible={forceVisiblePrimary}
>
<SettingsRow
label={themeLabel}
control={
<SettingsSegmentedControl
ariaLabel={themeLabel}
value={settings.theme}
onChange={(option) => {
updateSettings({ theme: option })
applyTheme(option)
}}
options={[
{
value: 'system',
label: translate('auto.components.settings.AppearancePane.fb0e0b4453', 'System')
},
{
value: 'dark',
label: translate('auto.components.settings.AppearancePane.7d26ccabe8', 'Dark')
},
{
value: 'light',
label: translate('auto.components.settings.AppearancePane.fd89b5487c', 'Light')
}
]}
/>
}
/>
</SearchableSetting>
<SearchableSetting
title={translate('auto.components.settings.AppearancePane.5e6d7aba8d', 'UI Zoom')}
description={zoomEntry?.description}
keywords={zoomEntry?.keywords ?? ['zoom', 'scale', 'shortcut']}
forceVisible={forceVisiblePrimary}
>
<SettingsRow
label={translate('auto.components.settings.AppearancePane.5e6d7aba8d', 'UI Zoom')}
// Why: keep only the shortcut hint — the control itself makes "scale the
// interface" obvious, but the keyboard gesture and its terminal-pane
// exception are not discoverable from the buttons alone.
description={
<>
<ShortcutHintList combos={zoomInKeyCombos} /> /{' '}
<ShortcutHintList combos={zoomOutKeyCombos} />{' '}
{translate(
'auto.components.settings.AppearancePane.ef89200c1f',
'when not in a terminal pane.'
)}
</>
}
control={<UIZoomControl />}
/>
</SearchableSetting>
<SearchableSetting
title={translate('auto.components.settings.AppearancePane.102d6b5f9b', 'IDE Font')}
description={typographyEntry?.description}
keywords={typographyEntry?.keywords ?? ['font', 'typeface', 'typography']}
forceVisible={forceVisiblePrimary}
>
<SettingsRow
label={translate('auto.components.settings.AppearancePane.102d6b5f9b', 'IDE Font')}
control={
<FontAutocomplete
value={settings.appFontFamily}
suggestions={fontSuggestions}
placeholder={DEFAULT_APP_FONT_FAMILY}
onChange={(value) =>
updateSettings({ appFontFamily: value.trim() || DEFAULT_APP_FONT_FAMILY })
}
/>
}
/>
</SearchableSetting>
{showAdvanced ? (
<AppearanceAdvancedDisclosure showTopBorder={false}>
<div className="divide-y divide-border/40">
{SHOW_UI_LANGUAGE_SETTING ? (
<SearchableSetting
title={translate('settings.appearance.language.title', 'Language')}
description={languageEntry?.description}
keywords={languageEntry?.keywords ?? []}
>
<SettingsRow
label={translate('settings.appearance.language.title', 'Language')}
control={
<Select
value={settings.uiLanguage}
onValueChange={(value) => updateSettings({ uiLanguage: value as UiLanguage })}
>
<SelectTrigger
size="sm"
className="min-w-[220px]"
aria-label={translate('settings.appearance.language.title', 'Language')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{UI_LANGUAGE_CHOICES.map((choice) => (
<SelectItem key={choice.value} value={choice.value}>
{getUiLanguageChoiceLabel(choice, translate)}
</SelectItem>
))}
</SelectContent>
</Select>
}
/>
</SearchableSetting>
) : null}
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.9868f39007',
'Titlebar App Name'
)}
description={titlebarEntry?.description}
keywords={titlebarEntry?.keywords ?? ['titlebar', 'orca', 'app', 'name']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.9868f39007',
'Titlebar App Name'
)}
checked={settings.showTitlebarAppName}
onChange={() =>
updateSettings({ showTitlebarAppName: !settings.showTitlebarAppName })
}
/>
</SearchableSetting>
{isDesktopWindows ? (
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.2edf606c46',
'Minimize to Tray on Close'
)}
description={systemTrayEntry?.description}
keywords={systemTrayEntry?.keywords ?? ['tray', 'minimize', 'close']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.2edf606c46',
'Minimize to Tray on Close'
)}
// Why: platform constraint + "close keeps Orca running" consequence are
// both non-obvious from the label alone.
description={translate(
'auto.components.settings.AppearancePane.b707773a0d',
'When enabled, closing the window keeps Orca running in the system tray instead of quitting.'
)}
checked={settings.minimizeToTrayOnClose === true}
onChange={() =>
updateSettings({ minimizeToTrayOnClose: !settings.minimizeToTrayOnClose })
}
/>
</SearchableSetting>
) : null}
</div>
</AppearanceAdvancedDisclosure>
) : null}
</div>
)
}
@@ -10,10 +10,18 @@ import type { GlobalSettings } from '../../../../shared/types'
const mocks = vi.hoisted(() => ({
state: {
availableStatusBarToggles: [] as {
description: string
id: 'ports'
keywords: string[]
title: string
toggleDescription: string
}[],
settingsSearchQuery: 'automations',
statusBarItems: [],
toggleStatusBarItem: vi.fn(),
recordFeatureInteraction: vi.fn()
recordFeatureInteraction: vi.fn(),
setWorktreeCardMode: vi.fn()
}
}))
@@ -26,7 +34,7 @@ vi.mock('@/hooks/useShortcutLabel', () => ({
}))
vi.mock('../status-bar/use-available-status-bar-toggles', () => ({
useAvailableStatusBarToggles: () => []
useAvailableStatusBarToggles: () => mocks.state.availableStatusBarToggles
}))
vi.mock('./TerminalAppearanceSection', () => ({
@@ -85,6 +93,7 @@ vi.mock('../ui/select', async () => {
})
import { AppearancePane } from './AppearancePane'
import { TooltipProvider } from '../ui/tooltip'
const mountedRoots: Root[] = []
@@ -130,16 +139,18 @@ async function renderAppearancePane(
await act(async () => {
root.render(
<I18nextProvider i18n={i18n}>
<AppearancePane
settings={settings}
updateSettings={updateSettings}
applyTheme={vi.fn()}
fontSuggestions={[]}
terminalFontSuggestions={[]}
systemPrefersDark={false}
ghostty={createGhosttyStub() as never}
warpThemes={createWarpThemesStub() as never}
/>
<TooltipProvider>
<AppearancePane
settings={settings}
updateSettings={updateSettings}
applyTheme={vi.fn()}
fontSuggestions={[]}
terminalFontSuggestions={[]}
systemPrefersDark={false}
ghostty={createGhosttyStub() as never}
warpThemes={createWarpThemesStub() as never}
/>
</TooltipProvider>
</I18nextProvider>
)
})
@@ -159,7 +170,22 @@ describe('AppearancePane', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.state.availableStatusBarToggles = []
mocks.state.settingsSearchQuery = 'automations'
// UIZoomControl reads window.api.ui on mount; the inline-expansion pane can
// render the full Interface section, so provide a minimal renderer bridge
// without clobbering happy-dom's window.location.
;(window as unknown as { api: unknown }).api = {
ui: {
getZoomLevel: () => 0,
onTerminalZoom: () => () => {},
set: vi.fn()
}
}
})
afterEach(() => {
delete (window as unknown as { api?: unknown }).api
})
it('renders the language dropdown with system, english, chinese, korean, japanese, and spanish options', async () => {
@@ -236,4 +262,117 @@ describe('AppearancePane', () => {
expect(updateSettings).toHaveBeenCalledWith({ showAutomationsButton: true })
})
it('changes workspace card layout from the Appearance sidebar controls', async () => {
mocks.state.settingsSearchQuery = 'workspace card layout'
const settings = {
...getDefaultSettings('/tmp'),
compactWorktreeCards: false
}
const container = await renderAppearancePane(settings)
const compactButton = Array.from(
container.querySelectorAll<HTMLButtonElement>('button[role="radio"]')
).find((button) => button.textContent === 'Compact')
expect(compactButton).toBeDefined()
await act(async () => {
compactButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(mocks.state.setWorktreeCardMode).toHaveBeenCalledWith('Compact')
})
it('renders the three top-level section rows and no Code & Markdown row when not searching', async () => {
mocks.state.settingsSearchQuery = ''
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
expect(container.textContent).toContain('Interface')
expect(container.textContent).toContain('Terminal')
expect(container.textContent).toContain('Window & Sidebar')
// Code & Markdown is intentionally omitted — Orca has no Appearance-level
// code/markdown settings, so the row would be empty.
expect(container.textContent).not.toContain('Code & Markdown')
})
it('keeps the app icon control at the bottom of the pane, after the section rows', async () => {
mocks.state.settingsSearchQuery = ''
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
const buttons = Array.from(container.querySelectorAll('button'))
const interfaceRow = buttons.find((button) => button.textContent?.includes('Interface'))
const appIconImage = container.querySelector<HTMLImageElement>('img[alt="Selected app icon"]')
expect(interfaceRow).toBeDefined()
expect(appIconImage).not.toBeNull()
// The App Icon block sits after the Interface section row in document order.
expect(
interfaceRow &&
appIconImage &&
interfaceRow.compareDocumentPosition(appIconImage) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy()
})
it('reveals an advanced sidebar control when its search matches, even though it is hidden by default', async () => {
// The Show Tasks Button toggle lives behind the Window & Sidebar Advanced
// disclosure; with no search it stays collapsed, but a matching query must
// force the disclosure open so the control is reachable.
mocks.state.settingsSearchQuery = ''
const collapsedContainer = await renderAppearancePane(getDefaultSettings('/tmp'))
expect(
collapsedContainer.querySelector('button[role="switch"][aria-label="Show Tasks Button"]')
).toBeNull()
mocks.state.settingsSearchQuery = 'tasks'
const searchedContainer = await renderAppearancePane(getDefaultSettings('/tmp'))
expect(
searchedContainer.querySelector('button[role="switch"][aria-label="Show Tasks Button"]')
).not.toBeNull()
})
it('keeps description-only search matches visible after helper text is hidden', async () => {
mocks.state.settingsSearchQuery = 'app window'
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
expect(container.textContent).toContain('Theme')
expect(container.textContent).not.toContain('Advanced')
})
it('shows useful primary rows for a Window & Sidebar section-label search', async () => {
mocks.state.settingsSearchQuery = 'Window & Sidebar'
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
expect(container.textContent).toContain('Left Sidebar Appearance')
expect(container.textContent).toContain('Status Bar')
expect(container.textContent).not.toContain('Advanced')
})
it('expands status bar controls for a section-label search', async () => {
mocks.state.availableStatusBarToggles = [
{
id: 'ports',
title: 'Ports',
description: 'Show live workspace ports in the status bar.',
toggleDescription: 'Show Ports in the status bar.',
keywords: ['status bar', 'ports']
}
]
mocks.state.settingsSearchQuery = 'status bar'
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
expect(container.querySelector('button[role="switch"][aria-label="Ports"]')).not.toBeNull()
})
it('collapses sibling sections so only the Interface section is expanded by default', async () => {
mocks.state.settingsSearchQuery = ''
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
const expanded = Array.from(
container.querySelectorAll<HTMLButtonElement>('button[aria-expanded="true"]')
).filter((button) => button.getAttribute('aria-controls')?.startsWith('appearance-section-'))
expect(expanded).toHaveLength(1)
expect(expanded[0]?.textContent).toContain('Interface')
})
})
@@ -1,36 +1,22 @@
/* eslint-disable max-lines -- Why: AppearancePane keeps theme, typography, zoom, and status-bar
visibility settings together so the searchable settings rows share one filtered surface. */
import type React from 'react'
import { useState } from 'react'
import { AppWindow, PanelLeft, TerminalSquare } from 'lucide-react'
import type { GlobalSettings } from '../../../../shared/types'
import { Separator } from '../ui/separator'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { UIZoomControl } from './UIZoomControl'
import { AppearanceSection } from './AppearanceSection'
import { AppearanceInterfaceSection } from './AppearanceInterfaceSection'
import { AppearanceWindowSidebarSection } from './AppearanceWindowSidebarSection'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch } from './settings-search'
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search'
import { useAppStore } from '../../store'
import { useShortcutKeyComboDetails, type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
import { ShortcutKeyCombo } from '../ShortcutKeyCombo'
import {
FontAutocomplete,
SettingsRow,
SettingsSegmentedControl,
SettingsSubsectionHeader,
SettingsSwitchRow
} from './SettingsFormControls'
import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants'
import { normalizeAppIconId } from '../../../../shared/app-icon'
import { useAvailableStatusBarToggles } from '../status-bar/use-available-status-bar-toggles'
import {
getAppIconEntries,
getAppearancePaneSearchEntries,
getLanguageEntries,
getLayoutEntries,
getLeftSidebarAppearanceEntry,
getSidebarEntries,
getStatusBarEntries,
getStatusBarToggles,
getSystemTrayEntries,
getThemeEntries,
getTitlebarEntries,
@@ -42,17 +28,15 @@ import { TerminalAppearanceSection } from './TerminalAppearanceSection'
import type { UseGhosttyImportReturn } from './useGhosttyImport'
import type { UseWarpThemeImportReturn } from './useWarpThemeImport'
import { AppIconSelector } from './AppIconSelector'
import { normalizeAppIconId } from '../../../../shared/app-icon'
import { getRendererAppPlatform } from '@/lib/renderer-app-platform'
import { isWebClientLocation } from '@/lib/web-client-location'
import {
getUiLanguageChoiceLabel,
SHOW_UI_LANGUAGE_SETTING,
UI_LANGUAGE_CHOICES
} from '@/i18n/supported-languages'
import { SHOW_UI_LANGUAGE_SETTING } from '@/i18n/supported-languages'
import { translate } from '@/i18n/i18n'
import type { UiLanguage } from '../../../../shared/ui-language'
import { LeftSidebarAppearanceSetting } from './LeftSidebarAppearanceSetting'
import { getWorkspaceCardLayoutEntry } from './appearance-sidebar-search'
import {
getLeftSidebarAppearanceEntry,
getWorkspaceCardLayoutEntry
} from './appearance-sidebar-search'
export { getAppearancePaneSearchEntries }
type AppearancePaneProps = {
@@ -66,28 +50,16 @@ type AppearancePaneProps = {
warpThemes: UseWarpThemeImportReturn
}
function ShortcutHintList({ combos }: { combos: ShortcutKeyComboDetails[] }): React.JSX.Element {
if (combos.length === 0) {
return (
<span className="text-xs text-muted-foreground">
{translate('auto.components.settings.AppearancePane.3057983501', 'Unassigned')}
</span>
)
}
type AppearanceSectionKey = 'interface' | 'terminal' | 'window'
return (
<span className="inline-flex flex-wrap items-center gap-1 align-middle">
{combos.map((combo) => (
<ShortcutKeyCombo
key={combo.keys.join('-')}
keys={combo.keys}
doubleTap={combo.doubleTap}
className="inline-flex gap-0.5"
separatorClassName="text-[10px] text-muted-foreground"
/>
))}
</span>
)
function resolveThemeSummary(theme: GlobalSettings['theme']): string {
if (theme === 'system') {
return translate('auto.components.settings.AppearancePane.fb0e0b4453', 'System')
}
if (theme === 'light') {
return translate('auto.components.settings.AppearancePane.fd89b5487c', 'Light')
}
return translate('auto.components.settings.AppearancePane.7d26ccabe8', 'Dark')
}
export function AppearancePane({
@@ -101,479 +73,165 @@ export function AppearancePane({
warpThemes
}: AppearancePaneProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
const isWebClient = isWebClientLocation()
// Why: the system tray behavior is desktop-Electron Windows-only; a Windows
// browser web client has no local tray to control.
const isDesktopWindows = getRendererAppPlatform() === 'win32' && !isWebClient
const zoomInKeyCombos = useShortcutKeyComboDetails('zoom.in')
const zoomOutKeyCombos = useShortcutKeyComboDetails('zoom.out')
const statusBarItems = useAppStore((state) => state.statusBarItems)
const toggleStatusBarItem = useAppStore((state) => state.toggleStatusBarItem)
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
const visibleStatusBarToggles = useAvailableStatusBarToggles(getStatusBarToggles())
const terminalAppearanceSearchEntries = getTerminalAppearanceSearchEntries({
showWarpImport: !isWebClient
const [manuallyOpenSection, setManuallyOpenSection] = useState<AppearanceSectionKey | null>(
'interface'
)
const interfaceTitle = translate(
'auto.components.settings.AppearancePane.interfaceTitle',
'Interface'
)
const terminalTitle = translate(
'auto.components.settings.AppearancePane.terminalTitle',
'Terminal'
)
const windowSidebarTitle = translate(
'auto.components.settings.AppearancePane.windowSidebarTitle',
'Window & Sidebar'
)
const windowSidebarSummary = translate(
'auto.components.settings.AppearancePane.windowSidebarSummary',
'Sidebar, status bar, and file explorer'
)
// Search-entry buckets per section so a query can force-open the matching one.
const interfaceSearchEntries = [
{ title: interfaceTitle },
...getThemeEntries(),
...getZoomEntries(),
...getTypographyEntries(),
...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []),
...getTitlebarEntries(),
...getSystemTrayEntries({ showSystemTray: isDesktopWindows })
]
const terminalSearchEntries = [
{ title: terminalTitle },
...getTerminalAppearanceSearchEntries({ showWarpImport: !isWebClient })
]
const windowSearchEntries = [
{
title: windowSidebarTitle,
description: windowSidebarSummary
},
...getStatusBarEntries(),
...getSidebarEntries(),
...getLayoutEntries(),
getLeftSidebarAppearanceEntry(),
getWorkspaceCardLayoutEntry()
]
const interfaceMatches = matchesSettingsSearch(searchQuery, interfaceSearchEntries)
const terminalMatches = matchesSettingsSearch(searchQuery, terminalSearchEntries)
const windowMatches = matchesSettingsSearch(searchQuery, windowSearchEntries)
const interfaceLabelMatches = matchesSettingsSearch(searchQuery, { title: interfaceTitle })
const terminalLabelMatches = matchesSettingsSearch(searchQuery, { title: terminalTitle })
const windowLabelMatches = matchesSettingsSearch(searchQuery, {
title: windowSidebarTitle,
description: windowSidebarSummary
})
const systemTrayEntries = getSystemTrayEntries({ showSystemTray: isDesktopWindows })
const leftSidebarAppearanceEntry = getLeftSidebarAppearanceEntry()
const workspaceCardLayoutEntry = getWorkspaceCardLayoutEntry()
const visibleSections = [
matchesSettingsSearch(searchQuery, getThemeEntries()) ||
(SHOW_UI_LANGUAGE_SETTING && matchesSettingsSearch(searchQuery, getLanguageEntries())) ||
matchesSettingsSearch(searchQuery, getZoomEntries()) ||
matchesSettingsSearch(searchQuery, getTypographyEntries()) ? (
<section key="interface" className="divide-y divide-border/40">
{matchesSettingsSearch(searchQuery, getThemeEntries()) ? (
<SearchableSetting
title={translate('auto.components.settings.AppearancePane.932ff1fbff', 'Theme')}
description={translate(
'auto.components.settings.AppearancePane.0f28e7b30c',
'Choose how Orca looks in the app window.'
)}
keywords={getThemeEntries()[0]?.keywords ?? ['dark', 'light', 'system']}
>
<SettingsRow
label={translate('auto.components.settings.AppearancePane.932ff1fbff', 'Theme')}
description={translate(
'auto.components.settings.AppearancePane.0f28e7b30c',
'Choose how Orca looks in the app window.'
)}
control={
<SettingsSegmentedControl
ariaLabel={translate(
'auto.components.settings.AppearancePane.932ff1fbff',
'Theme'
)}
value={settings.theme}
onChange={(option) => {
updateSettings({ theme: option })
applyTheme(option)
}}
options={[
{
value: 'system',
label: translate(
'auto.components.settings.AppearancePane.fb0e0b4453',
'System'
)
},
{
value: 'dark',
label: translate('auto.components.settings.AppearancePane.7d26ccabe8', 'Dark')
},
{
value: 'light',
label: translate(
'auto.components.settings.AppearancePane.fd89b5487c',
'Light'
)
}
]}
/>
}
/>
</SearchableSetting>
) : null}
const appIconMatches = matchesSettingsSearch(searchQuery, getAppIconEntries())
{SHOW_UI_LANGUAGE_SETTING && matchesSettingsSearch(searchQuery, getLanguageEntries()) ? (
<SearchableSetting
title={translate('settings.appearance.language.title', 'Language')}
description={translate(
'settings.appearance.language.description',
'Choose the language used by the Orca interface.'
)}
keywords={getLanguageEntries()[0]?.keywords ?? []}
>
<SettingsRow
label={translate('settings.appearance.language.title', 'Language')}
description={translate(
'settings.appearance.language.description',
'Choose the language used by the Orca interface.'
)}
control={
<Select
value={settings.uiLanguage}
onValueChange={(value) => updateSettings({ uiLanguage: value as UiLanguage })}
>
<SelectTrigger
size="sm"
className="min-w-[220px]"
aria-label={translate('settings.appearance.language.title', 'Language')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{UI_LANGUAGE_CHOICES.map((choice) => (
<SelectItem key={choice.value} value={choice.value}>
{getUiLanguageChoiceLabel(choice, translate)}
</SelectItem>
))}
</SelectContent>
</Select>
}
/>
</SearchableSetting>
) : null}
// While searching, force-open every section that contains a match so its
// controls (including advanced ones) are revealed; otherwise the accordion
// shows exactly one manually-chosen section.
function isSectionOpen(key: AppearanceSectionKey): boolean {
if (isSearching) {
return key === 'interface'
? interfaceMatches
: key === 'terminal'
? terminalMatches
: windowMatches
}
return manuallyOpenSection === key
}
{matchesSettingsSearch(searchQuery, getZoomEntries()) ? (
<SearchableSetting
title={translate('auto.components.settings.AppearancePane.5e6d7aba8d', 'UI Zoom')}
description={translate(
'auto.components.settings.AppearancePane.622e1c3465',
'Scale the entire application interface.'
)}
keywords={getZoomEntries()[0]?.keywords ?? ['zoom', 'scale', 'shortcut']}
>
<SettingsRow
label={translate('auto.components.settings.AppearancePane.5e6d7aba8d', 'UI Zoom')}
description={
<>
{translate(
'auto.components.settings.AppearancePane.f687711a9b',
'Scale the entire application interface. Use'
)}{' '}
<ShortcutHintList combos={zoomInKeyCombos} /> /{' '}
<ShortcutHintList combos={zoomOutKeyCombos} />{' '}
{translate(
'auto.components.settings.AppearancePane.ef89200c1f',
'when not in a terminal pane.'
)}
</>
}
control={<UIZoomControl />}
/>
</SearchableSetting>
) : null}
function toggleSection(key: AppearanceSectionKey): void {
setManuallyOpenSection((current) => (current === key ? null : key))
}
{matchesSettingsSearch(searchQuery, getTypographyEntries()) ? (
<SearchableSetting
title={translate('auto.components.settings.AppearancePane.102d6b5f9b', 'IDE Font')}
description={translate(
'auto.components.settings.AppearancePane.42554f615f',
'Choose the font used by the Orca interface.'
)}
keywords={getTypographyEntries()[0]?.keywords ?? ['font', 'typeface', 'typography']}
>
<SettingsRow
alignTop
label={translate('auto.components.settings.AppearancePane.102d6b5f9b', 'IDE Font')}
description={translate(
'auto.components.settings.AppearancePane.42554f615f',
'Choose the font used by the Orca interface.'
)}
control={
<FontAutocomplete
value={settings.appFontFamily}
suggestions={fontSuggestions}
placeholder={DEFAULT_APP_FONT_FAMILY}
onChange={(value) =>
updateSettings({ appFontFamily: value.trim() || DEFAULT_APP_FONT_FAMILY })
}
/>
}
/>
</SearchableSetting>
) : null}
</section>
) : null,
matchesSettingsSearch(searchQuery, terminalAppearanceSearchEntries) ? (
<TerminalAppearanceSection
key="terminal-appearance"
settings={settings}
updateSettings={updateSettings}
systemPrefersDark={systemPrefersDark}
terminalFontSuggestions={terminalFontSuggestions}
ghostty={ghostty}
warpThemes={warpThemes}
/>
) : null,
matchesSettingsSearch(searchQuery, getLayoutEntries()) ? (
<section key="layout" className="space-y-3">
<SettingsSubsectionHeader
title={translate('auto.components.settings.AppearancePane.d496901cd0', 'File Explorer')}
/>
const interfaceSummary = `${resolveThemeSummary(settings.theme)} · ${
settings.appFontFamily ||
translate('auto.components.settings.AppearancePane.interfaceDefaultFont', 'Default font')
}`
const terminalSummary = `${
settings.terminalFontFamily ||
translate('auto.components.settings.AppearancePane.terminalDefaultFont', 'Default font')
} · ${settings.terminalFontSize}px`
<div className="divide-y divide-border/40">
<SearchableSetting
title={
getLayoutEntries()[0]?.title ??
translate(
'auto.components.settings.AppearancePane.0fafabcf35',
'Show Git-Ignored Files'
)
}
description={
getLayoutEntries()[0]?.description ??
translate(
'auto.components.settings.AppearancePane.75f07ab60c',
'Show files matched by .gitignore in the file explorer.'
)
}
keywords={getLayoutEntries()[0]?.keywords ?? ['git', 'gitignore', 'ignored']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.0fafabcf35',
'Show Git-Ignored Files'
)}
description={translate(
'auto.components.settings.AppearancePane.e9f2ca5582',
'Turn off to hide files matched by .gitignore from the file explorer.'
)}
checked={settings.showGitIgnoredFiles ?? true}
onChange={() =>
updateSettings({ showGitIgnoredFiles: !(settings.showGitIgnoredFiles ?? true) })
}
/>
</SearchableSetting>
</div>
</section>
) : null,
matchesSettingsSearch(searchQuery, getTitlebarEntries()) ? (
<section key="titlebar" className="space-y-3">
<SettingsSubsectionHeader
title={translate('auto.components.settings.AppearancePane.6a272ca553', 'Titlebar')}
description={translate(
'auto.components.settings.AppearancePane.4de76f6902',
'Control what appears in the application titlebar.'
)}
/>
return (
<div className="space-y-2.5">
{interfaceMatches ? (
<AppearanceSection
id="interface"
icon={<AppWindow aria-hidden="true" />}
title={interfaceTitle}
summary={interfaceSummary}
open={isSectionOpen('interface')}
onToggle={() => toggleSection('interface')}
>
<AppearanceInterfaceSection
settings={settings}
updateSettings={updateSettings}
applyTheme={applyTheme}
fontSuggestions={fontSuggestions}
isDesktopWindows={isDesktopWindows}
forceVisiblePrimary={interfaceLabelMatches}
/>
</AppearanceSection>
) : null}
<div className="divide-y divide-border/40">
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.9868f39007',
'Titlebar App Name'
)}
description={translate(
'auto.components.settings.AppearancePane.2df8f79aa5',
'Show Orca in the titlebar.'
)}
keywords={getTitlebarEntries()[0]?.keywords ?? ['titlebar', 'orca', 'app', 'name']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.9868f39007',
'Titlebar App Name'
)}
description={translate(
'auto.components.settings.AppearancePane.2df8f79aa5',
'Show Orca in the titlebar.'
)}
checked={settings.showTitlebarAppName}
onChange={() =>
updateSettings({ showTitlebarAppName: !settings.showTitlebarAppName })
}
/>
</SearchableSetting>
</div>
</section>
) : null,
isDesktopWindows && matchesSettingsSearch(searchQuery, systemTrayEntries) ? (
<section key="system-tray" className="space-y-3">
<SettingsSubsectionHeader
title={translate('auto.components.settings.AppearancePane.872af9556e', 'System Tray')}
/>
{/* Why: Code & Markdown is intentionally omitted. Orca has no Appearance-level
code/markdown settings — the Monaco editor reuses the terminal font and
there is no markdown-style or line-number setting — so a fourth row would
be empty. We surface only the three sections that hold real controls
rather than fabricate settings. */}
<div className="divide-y divide-border/40">
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.2edf606c46',
'Minimize to Tray on Close'
)}
description={translate(
'auto.components.settings.AppearancePane.b707773a0d',
'When enabled, closing the window keeps Orca running in the system tray instead of quitting.'
)}
keywords={systemTrayEntries[0]?.keywords ?? ['tray', 'minimize', 'close']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.2edf606c46',
'Minimize to Tray on Close'
)}
description={translate(
'auto.components.settings.AppearancePane.b707773a0d',
'When enabled, closing the window keeps Orca running in the system tray instead of quitting.'
)}
checked={settings.minimizeToTrayOnClose === true}
onChange={() =>
updateSettings({ minimizeToTrayOnClose: !settings.minimizeToTrayOnClose })
}
/>
</SearchableSetting>
</div>
</section>
) : null,
matchesSettingsSearch(searchQuery, getStatusBarEntries()) ? (
<section key="status-bar" className="space-y-3">
<SettingsSubsectionHeader
title={translate('auto.components.settings.AppearancePane.3e4175e5c6', 'Status Bar')}
description={translate(
'auto.components.settings.AppearancePane.ea943d0db0',
'Choose which indicators appear at the bottom of the window. You can also right-click the status bar for the same toggles.'
)}
/>
{terminalMatches ? (
<AppearanceSection
id="terminal"
icon={<TerminalSquare aria-hidden="true" />}
title={terminalTitle}
summary={terminalSummary}
open={isSectionOpen('terminal')}
onToggle={() => toggleSection('terminal')}
>
<TerminalAppearanceSection
settings={settings}
updateSettings={updateSettings}
systemPrefersDark={systemPrefersDark}
terminalFontSuggestions={terminalFontSuggestions}
ghostty={ghostty}
warpThemes={warpThemes}
forceVisiblePrimary={terminalLabelMatches}
/>
</AppearanceSection>
) : null}
<div className="divide-y divide-border/40">
{visibleStatusBarToggles.map((toggle) => {
const enabled = statusBarItems.includes(toggle.id)
return (
<SearchableSetting
key={toggle.id}
title={toggle.title}
description={toggle.description}
keywords={toggle.keywords}
>
<SettingsSwitchRow
label={toggle.title}
description={toggle.toggleDescription}
checked={enabled}
onChange={() => {
if (toggle.id === 'resource-usage') {
recordFeatureInteraction('resource-manager')
} else if (toggle.id === 'ports') {
recordFeatureInteraction('ports')
} else if (toggle.id === 'ssh') {
recordFeatureInteraction('ssh')
} else if (
toggle.id === 'claude' ||
toggle.id === 'codex' ||
toggle.id === 'gemini' ||
toggle.id === 'opencode-go'
) {
recordFeatureInteraction('usage-tracking')
}
toggleStatusBarItem(toggle.id)
}}
ariaLabel={toggle.title}
/>
</SearchableSetting>
)
})}
</div>
</section>
) : null,
matchesSettingsSearch(searchQuery, getSidebarEntries()) ? (
<section key="sidebar" className="space-y-3">
<SettingsSubsectionHeader
title={translate('auto.components.settings.AppearancePane.dc29f3cc0d', 'Sidebar')}
/>
{windowMatches ? (
<AppearanceSection
id="window"
icon={<PanelLeft aria-hidden="true" />}
title={windowSidebarTitle}
summary={windowSidebarSummary}
open={isSectionOpen('window')}
onToggle={() => toggleSection('window')}
>
<AppearanceWindowSidebarSection
settings={settings}
updateSettings={updateSettings}
forceVisiblePrimary={windowLabelMatches}
/>
</AppearanceSection>
) : null}
<div className="divide-y divide-border/40">
<SearchableSetting
title={leftSidebarAppearanceEntry.title}
description={leftSidebarAppearanceEntry.description}
keywords={leftSidebarAppearanceEntry.keywords}
className="space-y-2"
>
<LeftSidebarAppearanceSetting settings={settings} updateSettings={updateSettings} />
</SearchableSetting>
{/* Why: this setting lives with the sidebar layout controls; Settings only
points people to it so we do not create a second stateful control. */}
<SearchableSetting
title={workspaceCardLayoutEntry.title}
description={workspaceCardLayoutEntry.description}
keywords={workspaceCardLayoutEntry.keywords}
>
<SettingsRow
label={workspaceCardLayoutEntry.title}
description={translate(
'auto.components.settings.AppearancePane.workspaceCardLayoutGuidance',
'Use the workspace sidebar options menu > Card layout > Compact.'
)}
control={null}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.cf81907069',
'Show Tasks Button'
)}
description={translate(
'auto.components.settings.AppearancePane.661942ab7f',
'Show the Tasks button at the top of the left sidebar.'
)}
keywords={getSidebarEntries()[0]?.keywords ?? ['tasks', 'sidebar', 'button']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.cf81907069',
'Show Tasks Button'
)}
description={translate(
'auto.components.settings.AppearancePane.661942ab7f',
'Show the Tasks button at the top of the left sidebar.'
)}
checked={settings.showTasksButton !== false}
onChange={() =>
updateSettings({ showTasksButton: !(settings.showTasksButton !== false) })
}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.511f270ebb',
'Show Automations Button'
)}
description={translate(
'auto.components.settings.AppearancePane.fa882a3e6b',
'Show the Automations button at the top of the left sidebar.'
)}
keywords={getSidebarEntries()[1]?.keywords ?? ['automations', 'automation', 'schedule']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.511f270ebb',
'Show Automations Button'
)}
description={translate(
'auto.components.settings.AppearancePane.fa882a3e6b',
'Show the Automations button at the top of the left sidebar.'
)}
checked={settings.showAutomationsButton !== false}
onChange={() =>
updateSettings({
showAutomationsButton: !(settings.showAutomationsButton !== false)
})
}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.9da1020447',
'Show Orca Mobile Button'
)}
description={translate(
'auto.components.settings.AppearancePane.5db6ba961f',
'Show the Orca Mobile button at the top of the left sidebar.'
)}
keywords={getSidebarEntries()[2]?.keywords ?? ['mobile', 'phone', 'sidebar']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.9da1020447',
'Show Orca Mobile Button'
)}
description={translate(
'auto.components.settings.AppearancePane.61d842eca0',
'Show the Orca Mobile shortcut in the sidebar. It remains available from Toolbox.'
)}
checked={settings.showMobileButton !== false}
onChange={() =>
updateSettings({ showMobileButton: !(settings.showMobileButton !== false) })
}
/>
</SearchableSetting>
</div>
</section>
) : null,
matchesSettingsSearch(searchQuery, getAppIconEntries()) ? (
<section key="app-icon" className="space-y-3">
{/* App icon stays at the bottom of Appearance as a small easter egg,
matching production — not buried inside Interface advanced. */}
{appIconMatches ? (
<SearchableSetting
title={translate('auto.components.settings.AppearancePane.ca1590d42f', 'App Icon')}
description={translate(
@@ -585,25 +243,14 @@ export function AppearancePane({
entry.description ?? '',
...(entry.keywords ?? [])
])}
className="max-w-none py-2"
className="max-w-none px-1 pt-2"
>
<AppIconSelector
value={normalizeAppIconId(settings.appIcon)}
onChange={(appIcon) => updateSettings({ appIcon })}
/>
</SearchableSetting>
</section>
) : null
].filter(Boolean)
return (
<div className="space-y-6">
{visibleSections.map((section, index) => (
<div key={index} className="space-y-6">
{index > 0 ? <Separator /> : null}
{section}
</div>
))}
) : null}
</div>
)
}
@@ -0,0 +1,78 @@
import type React from 'react'
import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
type AppearanceSectionProps = {
/** Stable id used for the accordion toggle + aria wiring. */
id: string
icon: React.ReactNode
title: React.ReactNode
/** Plain-language current value shown in the collapsed summary row. */
summary: React.ReactNode
open: boolean
onToggle: () => void
children: React.ReactNode
}
/** Compact summary row that expands its section inline. The parent owns the
* open state so opening one row can collapse the previously open one
* (accordion behavior) and search can force a section open. */
export function AppearanceSection({
id,
icon,
title,
summary,
open,
onToggle,
children
}: AppearanceSectionProps): React.JSX.Element {
const contentId = `appearance-section-${id}`
return (
<div
className={cn(
'overflow-hidden rounded-xl border border-border/50 bg-card transition-colors',
open && 'border-ring/40'
)}
>
<button
type="button"
aria-expanded={open}
aria-controls={contentId}
onClick={onToggle}
className="flex w-full items-center gap-3.5 px-4 py-3.5 text-left transition-colors hover:bg-accent/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-secondary text-foreground [&_svg]:size-4">
{icon}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold">{title}</span>
{!open ? (
<span className="block truncate text-xs text-muted-foreground">{summary}</span>
) : null}
</span>
<ChevronRight
className={cn(
'size-[18px] shrink-0 text-muted-foreground transition-transform',
open && 'rotate-90 text-foreground'
)}
/>
</button>
<div
className={cn(
'grid overflow-hidden transition-[grid-template-rows,opacity,border-color] duration-200 ease-out motion-reduce:transition-none',
open
? 'grid-rows-[1fr] border-t border-border/50 opacity-100'
: 'grid-rows-[0fr] border-t border-transparent opacity-0'
)}
aria-hidden={!open}
inert={!open}
>
<div className="min-h-0 overflow-hidden">
<div id={contentId} role="region" className="px-4 pt-1 pb-4">
{children}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,34 @@
import type React from 'react'
import { type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
import { ShortcutKeyCombo } from '../ShortcutKeyCombo'
import { translate } from '@/i18n/i18n'
/** Renders the primary keyboard shortcut combo inline, or an "Unassigned"
* hint when the action has no binding. Platform-aware glyphs come from
* ShortcutKeyCombo. */
export function ShortcutHintList({
combos
}: {
combos: ShortcutKeyComboDetails[]
}): React.JSX.Element {
if (combos.length === 0) {
return (
<span className="text-xs text-muted-foreground">
{translate('auto.components.settings.AppearancePane.3057983501', 'Unassigned')}
</span>
)
}
const primaryCombo = combos[0]
return (
<span className="inline-flex items-center align-middle">
<ShortcutKeyCombo
keys={primaryCombo.keys}
doubleTap={primaryCombo.doubleTap}
className="inline-flex gap-0.5"
separatorClassName="text-[10px] text-muted-foreground"
/>
</span>
)
}
@@ -0,0 +1,307 @@
import type React from 'react'
import type { GlobalSettings, StatusBarItem } from '../../../../shared/types'
import type { FeatureInteractionId } from '../../../../shared/feature-interaction-catalog'
import { SearchableSetting } from './SearchableSetting'
import { AppearanceAdvancedDisclosure } from './AppearanceAdvancedDisclosure'
import { useAppStore } from '../../store'
import {
SettingsRow,
SettingsSegmentedControl,
SettingsSubsectionHeader,
SettingsSwitchRow
} from './SettingsFormControls'
import { useAvailableStatusBarToggles } from '../status-bar/use-available-status-bar-toggles'
import { getLayoutEntries, getSidebarEntries, getStatusBarToggles } from './appearance-search'
import { LeftSidebarAppearanceSetting } from './LeftSidebarAppearanceSetting'
import {
getLeftSidebarAppearanceEntry,
getWorkspaceCardLayoutEntry
} from './appearance-sidebar-search'
import { translate } from '@/i18n/i18n'
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search'
type AppearanceWindowSidebarSectionProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
forceVisiblePrimary?: boolean
}
function recordStatusBarToggleInteraction(
id: StatusBarItem,
recordFeatureInteraction: (feature: FeatureInteractionId) => void
): void {
if (id === 'resource-usage') {
recordFeatureInteraction('resource-manager')
} else if (id === 'ports') {
recordFeatureInteraction('ports')
} else if (id === 'ssh') {
recordFeatureInteraction('ssh')
} else if (id === 'claude' || id === 'codex' || id === 'gemini' || id === 'opencode-go') {
recordFeatureInteraction('usage-tracking')
}
}
export function AppearanceWindowSidebarSection({
settings,
updateSettings,
forceVisiblePrimary = false
}: AppearanceWindowSidebarSectionProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
const statusBarItems = useAppStore((state) => state.statusBarItems)
const toggleStatusBarItem = useAppStore((state) => state.toggleStatusBarItem)
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
const setWorktreeCardMode = useAppStore((state) => state.setWorktreeCardMode)
const visibleStatusBarToggles = useAvailableStatusBarToggles(getStatusBarToggles())
const leftSidebarAppearanceEntry = getLeftSidebarAppearanceEntry()
const sidebarEntries = getSidebarEntries()
const workspaceCardLayoutEntry = getWorkspaceCardLayoutEntry()
const layoutEntries = getLayoutEntries()
const statusBarTitle = translate(
'auto.components.settings.AppearancePane.3e4175e5c6',
'Status Bar'
)
const statusBarDescription = translate(
'auto.components.settings.AppearancePane.statusBarDescription',
'Choose which indicators appear in the status bar.'
)
const statusBarKeywords = ['status bar', 'indicators']
const statusBarSectionMatches = matchesSettingsSearch(searchQuery, {
title: statusBarTitle,
description: statusBarDescription,
keywords: statusBarKeywords
})
const statusBarControlMatches = visibleStatusBarToggles.some((toggle) =>
matchesSettingsSearch(searchQuery, {
title: toggle.title,
description: toggle.description,
keywords: toggle.keywords
})
)
const sidebarAdvancedMatches = matchesSettingsSearch(searchQuery, [
workspaceCardLayoutEntry,
...sidebarEntries
])
const fileExplorerAdvancedMatches = matchesSettingsSearch(searchQuery, layoutEntries)
const showStatusBarControls = !isSearching || statusBarSectionMatches || statusBarControlMatches
const showSidebarAdvanced = !isSearching || sidebarAdvancedMatches
const showFileExplorerAdvanced = !isSearching || fileExplorerAdvancedMatches
const showAdvanced = showSidebarAdvanced || showFileExplorerAdvanced
return (
<div className="space-y-2">
<div className="divide-y divide-border/40">
<SearchableSetting
title={leftSidebarAppearanceEntry.title}
description={leftSidebarAppearanceEntry.description}
keywords={leftSidebarAppearanceEntry.keywords}
className="space-y-2"
forceVisible={forceVisiblePrimary}
>
<LeftSidebarAppearanceSetting settings={settings} updateSettings={updateSettings} />
</SearchableSetting>
<SearchableSetting
title={statusBarTitle}
keywords={statusBarKeywords}
forceVisible={forceVisiblePrimary || statusBarSectionMatches || statusBarControlMatches}
>
<SettingsRow label={statusBarTitle} description={statusBarDescription} control={null} />
{showStatusBarControls ? (
<div className="ml-4 divide-y divide-border/40 border-t border-border/40">
{visibleStatusBarToggles.map((toggle) => {
const enabled = statusBarItems.includes(toggle.id)
return (
<SearchableSetting
key={toggle.id}
title={toggle.title}
description={toggle.description}
keywords={toggle.keywords}
>
<SettingsSwitchRow
label={toggle.title}
description={toggle.toggleDescription}
checked={enabled}
onChange={() => {
recordStatusBarToggleInteraction(toggle.id, recordFeatureInteraction)
toggleStatusBarItem(toggle.id)
}}
ariaLabel={toggle.title}
/>
</SearchableSetting>
)
})}
</div>
) : null}
</SearchableSetting>
</div>
{showAdvanced ? (
<AppearanceAdvancedDisclosure contentClassName="ml-4 pt-4">
<div className="space-y-4">
{showSidebarAdvanced ? (
<div className="space-y-3">
<SettingsSubsectionHeader
title={translate('auto.components.settings.AppearancePane.dc29f3cc0d', 'Sidebar')}
/>
<div className="ml-4 divide-y divide-border/40">
{/* Why: this setting lives with the sidebar layout controls; Settings only
names that ownership so we do not create a second stateful control. */}
<SearchableSetting
title={workspaceCardLayoutEntry.title}
description={workspaceCardLayoutEntry.description}
keywords={workspaceCardLayoutEntry.keywords}
>
<SettingsRow
label={workspaceCardLayoutEntry.title}
description={workspaceCardLayoutEntry.description}
control={
<SettingsSegmentedControl
value={settings.compactWorktreeCards ? 'compact' : 'detailed'}
onChange={(value) =>
setWorktreeCardMode(value === 'compact' ? 'Compact' : 'Default')
}
ariaLabel={workspaceCardLayoutEntry.title}
options={[
{
value: 'detailed',
label: translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.cc17bd443b',
'Detailed'
)
},
{
value: 'compact',
label: translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.25105b28cb',
'Compact'
)
}
]}
/>
}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.cf81907069',
'Show Tasks Button'
)}
description={sidebarEntries[0]?.description}
keywords={sidebarEntries[0]?.keywords ?? ['tasks', 'sidebar', 'button']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.cf81907069',
'Show Tasks Button'
)}
checked={settings.showTasksButton !== false}
onChange={() =>
updateSettings({ showTasksButton: !(settings.showTasksButton !== false) })
}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.511f270ebb',
'Show Automations Button'
)}
description={sidebarEntries[1]?.description}
keywords={
sidebarEntries[1]?.keywords ?? ['automations', 'automation', 'schedule']
}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.511f270ebb',
'Show Automations Button'
)}
checked={settings.showAutomationsButton !== false}
onChange={() =>
updateSettings({
showAutomationsButton: !(settings.showAutomationsButton !== false)
})
}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.9da1020447',
'Show Orca Mobile Button'
)}
description={sidebarEntries[2]?.description}
keywords={sidebarEntries[2]?.keywords ?? ['mobile', 'phone', 'sidebar']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.9da1020447',
'Show Orca Mobile Button'
)}
// Why: clarify where the shortcut still lives after hiding it, so users
// don't think the feature is gone.
description={translate(
'auto.components.settings.AppearancePane.61d842eca0',
'Show the Orca Mobile shortcut in the sidebar. It remains available from Toolbox.'
)}
checked={settings.showMobileButton !== false}
onChange={() =>
updateSettings({ showMobileButton: !(settings.showMobileButton !== false) })
}
/>
</SearchableSetting>
</div>
</div>
) : null}
{showFileExplorerAdvanced ? (
<div className="space-y-3">
<SettingsSubsectionHeader
title={translate(
'auto.components.settings.AppearancePane.d496901cd0',
'File Explorer'
)}
/>
<div className="ml-4 divide-y divide-border/40">
<SearchableSetting
title={
layoutEntries[0]?.title ??
translate(
'auto.components.settings.AppearancePane.0fafabcf35',
'Show Git-Ignored Files'
)
}
description={layoutEntries[0]?.description}
keywords={layoutEntries[0]?.keywords ?? ['git', 'gitignore', 'ignored']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.0fafabcf35',
'Show Git-Ignored Files'
)}
// Why: define what "git-ignored" matches; the location (file explorer)
// is obvious from the section header.
description={translate(
'auto.components.settings.AppearancePane.gitIgnoredGlossary',
'Files matched by .gitignore.'
)}
checked={settings.showGitIgnoredFiles ?? true}
onChange={() =>
updateSettings({
showGitIgnoredFiles: !(settings.showGitIgnoredFiles ?? true)
})
}
/>
</SearchableSetting>
</div>
</div>
) : null}
</div>
</AppearanceAdvancedDisclosure>
) : null}
</div>
)
}
@@ -1,5 +1,66 @@
import { describe, expect, it } from 'vitest'
import { getLocalCommandSourcePolicyNotice } from './RepositoryHooksSection'
// @vitest-environment happy-dom
import React from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/types'
import { getLocalCommandSourcePolicyNotice, RepositoryHooksSection } from './RepositoryHooksSection'
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({
settings: {},
settingsSearchQuery: ''
})
}))
vi.mock('@/runtime/runtime-hooks-client', () => ({
readRuntimeIssueCommand: vi.fn().mockResolvedValue({ command: '', exists: false }),
writeRuntimeIssueCommand: vi.fn().mockResolvedValue(undefined)
}))
const repo: Repo = {
id: 'repo-1',
kind: 'git',
path: '/workspace/repo',
displayName: 'Repo',
badgeColor: 'blue',
addedAt: 1,
gitUsername: ''
}
function renderRepositoryHooksSection(args: {
onUpdateHookSettings: (settings: NonNullable<Repo['hookSettings']>) => void
}): { container: HTMLDivElement; root: Root } {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
act(() => {
root.render(
React.createElement(RepositoryHooksSection, {
repo,
yamlHooks: null,
hasHooksFile: false,
hooksInspectionReady: true,
mayNeedUpdate: false,
copiedTemplate: false,
forceVisible: true,
onCopyTemplate: () => {},
onUpdateHookSettings: args.onUpdateHookSettings
})
)
})
return { container, root }
}
let rendered: { container: HTMLDivElement; root: Root } | null = null
afterEach(() => {
act(() => rendered?.root.unmount())
rendered?.container.remove()
rendered = null
})
describe('getLocalCommandSourcePolicyNotice', () => {
it('does not show a notice when no local scripts are saved', () => {
@@ -72,3 +133,25 @@ describe('getLocalCommandSourcePolicyNotice', () => {
).toEqual({ kind: 'action', policy: 'run-both', label: 'Run both' })
})
})
describe('RepositoryHooksSection setup startup policy', () => {
it('persists wait-for-setup when the repository toggle is checked', () => {
const updates: NonNullable<Repo['hookSettings']>[] = []
rendered = renderRepositoryHooksSection({
onUpdateHookSettings: (settings) => updates.push(settings)
})
const waitSwitch = rendered.container.querySelector<HTMLElement>(
'[role="switch"][aria-label="Wait for setup to complete before starting agent"]'
)
expect(waitSwitch).toBeTruthy()
act(() => waitSwitch?.click())
expect(updates.at(-1)).toMatchObject({
setupAgentStartupPolicy: 'wait-for-setup',
setupRunPolicy: 'run-by-default',
scripts: { setup: '', archive: '' }
})
})
})
@@ -6,6 +6,7 @@ import type {
OrcaHooks,
Repo,
RepoHookSettings,
SetupAgentStartupPolicy,
SetupRunPolicy
} from '../../../../shared/types'
import { AlertTriangle, ChevronRight, Plus } from 'lucide-react'
@@ -14,6 +15,7 @@ import { useTranslation } from 'react-i18next'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { SearchableSetting } from './SearchableSetting'
import { SettingsSwitch } from './SettingsFormControls'
import { useAppStore } from '@/store'
import { readRuntimeIssueCommand, writeRuntimeIssueCommand } from '@/runtime/runtime-hooks-client'
import { DEFAULT_REPO_HOOK_SETTINGS } from './SettingsConstants'
@@ -39,7 +41,7 @@ type PolicyOption<P> = { policy: P; label: string; description: string }
const LOCAL_HOOK_NAMES = ['setup', 'archive'] as const
type LocalHookName = (typeof LOCAL_HOOK_NAMES)[number]
type HookSettingsPolicyDraft = Partial<
Pick<RepoHookSettings, 'setupRunPolicy' | 'commandSourcePolicy'>
Pick<RepoHookSettings, 'setupRunPolicy' | 'setupAgentStartupPolicy' | 'commandSourcePolicy'>
>
// Why: this is a literal issue-command template token, not app data for i18next to fill.
@@ -75,6 +77,7 @@ function areHookSettingsDraftsEqual(a: RepoHookSettings, b: RepoHookSettings): b
return (
a.mode === b.mode &&
a.setupRunPolicy === b.setupRunPolicy &&
a.setupAgentStartupPolicy === b.setupAgentStartupPolicy &&
a.commandSourcePolicy === b.commandSourcePolicy &&
a.scripts.setup === b.scripts.setup &&
a.scripts.archive === b.scripts.archive
@@ -770,6 +773,8 @@ export function RepositoryHooksSection({
const selectedSetupRunPolicy: SetupRunPolicy =
hookSettingsDraft.setupRunPolicy ?? 'run-by-default'
const selectedSetupAgentStartupPolicy: SetupAgentStartupPolicy =
hookSettingsDraft.setupAgentStartupPolicy ?? 'start-immediately'
const setupRunPolicyOptions = getSetupRunPolicyOptions()
const commandSourcePolicyOptions = getCommandSourcePolicyOptions()
const localHookFields = getLocalHookFields()
@@ -1043,26 +1048,59 @@ export function RepositoryHooksSection({
forceVisible={forceVisible}
keywords={['setup run policy', 'ask', 'run by default', 'skip by default']}
>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-border/50 bg-background/80 p-4 shadow-sm">
<div className="min-w-0">
<h5 className="text-sm font-semibold">
{translate(
'auto.components.settings.RepositoryHooksSection.793dcee97d',
'When to run'
<div className="space-y-4 rounded-2xl border border-border/50 bg-background/80 p-4 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h5 className="text-sm font-semibold">
{translate(
'auto.components.settings.RepositoryHooksSection.793dcee97d',
'When to run'
)}
</h5>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RepositoryHooksSection.21fb607a87',
'Default behavior when a new worktree is created.'
)}
</p>
</div>
<SegmentedPolicyToggle
options={setupRunPolicyOptions}
selected={selectedSetupRunPolicy}
onSelect={(policy) => updateHookSettingsPolicyDraft({ setupRunPolicy: policy })}
/>
</div>
<div className="flex items-start justify-between gap-4 border-t border-border/60 pt-4">
<div className="min-w-0 space-y-1">
<h5 className="text-sm font-semibold">
{translate(
'auto.components.settings.RepositoryHooksSection.waitForSetupBeforeAgent',
'Wait for setup to complete before starting agent'
)}
</h5>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RepositoryHooksSection.waitForSetupBeforeAgentHelp',
'Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup.'
)}
</p>
</div>
<SettingsSwitch
checked={selectedSetupAgentStartupPolicy === 'wait-for-setup'}
onChange={() =>
updateHookSettingsPolicyDraft({
setupAgentStartupPolicy:
selectedSetupAgentStartupPolicy === 'wait-for-setup'
? 'start-immediately'
: 'wait-for-setup'
})
}
ariaLabel={translate(
'auto.components.settings.RepositoryHooksSection.waitForSetupBeforeAgent',
'Wait for setup to complete before starting agent'
)}
</h5>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RepositoryHooksSection.21fb607a87',
'Default behavior when a new worktree is created.'
)}
</p>
/>
</div>
<SegmentedPolicyToggle
options={setupRunPolicyOptions}
selected={selectedSetupRunPolicy}
onSelect={(policy) => updateHookSettingsPolicyDraft({ setupRunPolicy: policy })}
/>
</div>
</SearchableSetting>
@@ -76,11 +76,19 @@ export function SettingsRow({
}: SettingsRowProps): React.JSX.Element {
return (
<div
className={cn('flex gap-4 py-2', alignTop ? 'items-start' : 'items-center justify-between')}
className={cn(
'flex gap-4',
description ? 'py-3' : 'py-2',
alignTop ? 'items-start' : 'items-center justify-between'
)}
>
<div className="min-w-0 flex-1 space-y-0.5">
<Label id={labelId}>{label}</Label>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
<div className={cn('min-w-0 flex-1', description ? 'space-y-1' : 'space-y-0.5')}>
<Label id={labelId} className="select-text">
{label}
</Label>
{description ? (
<p className="select-text text-xs text-muted-foreground">{description}</p>
) : null}
</div>
<div className="shrink-0">{control}</div>
</div>
@@ -218,16 +226,18 @@ type SettingsSubsectionHeaderProps = {
title: React.ReactNode
description?: React.ReactNode
action?: React.ReactNode
className?: string
}
/** Consistent subsection header: h3 text-sm font-semibold + optional muted description. */
export function SettingsSubsectionHeader({
title,
description,
action
action,
className
}: SettingsSubsectionHeaderProps): React.JSX.Element {
return (
<div className="flex items-start justify-between gap-3">
<div className={cn('flex items-start justify-between gap-3', className)}>
<div className="space-y-1">
<h3 className="text-sm font-semibold">{title}</h3>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
@@ -0,0 +1,188 @@
import type { GlobalSettings } from '../../../../shared/types'
import {
DEFAULT_TERMINAL_FONT_WEIGHT,
TERMINAL_FONT_WEIGHT_MAX,
TERMINAL_FONT_WEIGHT_MIN,
TERMINAL_FONT_WEIGHT_STEP,
normalizeTerminalFontWeight
} from '../../../../shared/terminal-fonts'
import {
fontFamilyHasKnownLigatures,
resolveTerminalLigaturesEnabled
} from '../../../../shared/terminal-ligatures'
import { NumberField, SettingsRow, SettingsSegmentedControl } from './SettingsFormControls'
import { SearchableSetting } from './SearchableSetting'
import { clampNumber } from '@/lib/terminal-theme'
import { translate } from '@/i18n/i18n'
import { getTerminalAdvancedTypographySearchEntries } from './terminal-typography-search'
type TerminalAdvancedTypographyControlsProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
}
/** Low-frequency terminal typography knobs (weight, line height, ligatures).
* Split out of the primary font controls so the default Terminal scan stays
* compact while these stay searchable inside the Advanced disclosure. */
export function TerminalAdvancedTypographyControls({
settings,
updateSettings
}: TerminalAdvancedTypographyControlsProps): React.JSX.Element {
const searchEntries = getTerminalAdvancedTypographySearchEntries()
return (
<div className="divide-y divide-border/40">
<SearchableSetting
title={translate(
'auto.components.settings.TerminalAppearanceSection.4aae5db258',
'Font Weight'
)}
description={searchEntries[0]?.description}
keywords={searchEntries[0]?.keywords ?? ['terminal', 'typography', 'weight']}
>
<NumberField
label={translate(
'auto.components.settings.TerminalAppearanceSection.4aae5db258',
'Font Weight'
)}
description=""
value={normalizeTerminalFontWeight(settings.terminalFontWeight)}
defaultValue={DEFAULT_TERMINAL_FONT_WEIGHT}
min={TERMINAL_FONT_WEIGHT_MIN}
max={TERMINAL_FONT_WEIGHT_MAX}
step={TERMINAL_FONT_WEIGHT_STEP}
suffix="100-900"
onChange={(value) =>
updateSettings({ terminalFontWeight: normalizeTerminalFontWeight(value) })
}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalAppearanceSection.c084eb7d4c',
'Line Height'
)}
description={searchEntries[1]?.description}
keywords={
searchEntries[1]?.keywords ?? ['terminal', 'typography', 'line height', 'spacing']
}
>
<NumberField
label={translate(
'auto.components.settings.TerminalAppearanceSection.c084eb7d4c',
'Line Height'
)}
description=""
value={settings.terminalLineHeight}
defaultValue={1}
min={1}
max={3}
step={0.1}
suffix="1-3"
onChange={(value) => updateSettings({ terminalLineHeight: clampNumber(value, 1, 3) })}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalAppearanceSection.be8da35e7f',
'Font Ligatures'
)}
description={searchEntries[2]?.description}
keywords={
searchEntries[2]?.keywords ?? [
'terminal',
'typography',
'ligatures',
'ligature',
'fira code',
'jetbrains mono',
'cascadia code',
'iosevka',
'calt',
'font features'
]
}
>
<SettingsRow
label={translate(
'auto.components.settings.TerminalAppearanceSection.be8da35e7f',
'Font Ligatures'
)}
// Why: "ligatures" is jargon; the per-state gloss tells the user whether
// Auto resolves on/off for their current font.
description={
settings.terminalLigatures === 'on'
? translate(
'auto.components.settings.TerminalAppearanceSection.7234abcd08',
'Always on. Fonts without ligatures simply render as-is.'
)
: settings.terminalLigatures === 'off'
? translate(
'auto.components.settings.TerminalAppearanceSection.04569feb07',
'Always off, even for fonts that ship them.'
)
: fontFamilyHasKnownLigatures(settings.terminalFontFamily)
? translate(
'auto.components.settings.TerminalAppearanceSection.400e950ca5',
'Auto - enabled for "{{value0}}".',
{ value0: settings.terminalFontFamily }
)
: translate(
'auto.components.settings.TerminalAppearanceSection.4b1f29598e',
'Auto - disabled for "{{value0}}".',
{ value0: settings.terminalFontFamily || 'the current font' }
)
}
control={
<SettingsSegmentedControl
ariaLabel={translate(
'auto.components.settings.TerminalAppearanceSection.be8da35e7f',
'Font Ligatures'
)}
value={settings.terminalLigatures ?? 'auto'}
onChange={(option) => updateSettings({ terminalLigatures: option })}
options={[
{
value: 'auto',
label: translate(
'auto.components.settings.TerminalAppearanceSection.bc9ff84d61',
'Auto'
)
},
{
value: 'on',
label: translate(
'auto.components.settings.TerminalAppearanceSection.84bd22f2cd',
'On'
)
},
{
value: 'off',
label: translate(
'auto.components.settings.TerminalAppearanceSection.870377082f',
'Off'
)
}
]}
/>
}
/>
<p className="sr-only" aria-live="polite">
{translate(
'auto.components.settings.TerminalAppearanceSection.31f6e61085',
'Ligatures are currently'
)}{' '}
{resolveTerminalLigaturesEnabled(settings.terminalLigatures, settings.terminalFontFamily)
? translate('auto.components.settings.TerminalAppearanceSection.4e7d41a9f0', 'enabled')
: translate(
'auto.components.settings.TerminalAppearanceSection.4415beb958',
'disabled'
)}
.
</p>
</SearchableSetting>
</div>
)
}
@@ -97,8 +97,8 @@ vi.mock('./SettingsFormControls', () => ({
}) {
return options?.map((option) => option.label) ?? null
},
SettingsSubsectionHeader: function SettingsSubsectionHeader() {
return null
SettingsSubsectionHeader: function SettingsSubsectionHeader({ action }: { action?: unknown }) {
return action ?? null
},
SettingsSwitchRow: function SettingsSwitchRow() {
return null
@@ -322,6 +322,30 @@ function findWarpThemeImportModal(node: unknown): ReactElementLike | null {
return null
}
function findComponentByTypeName(node: unknown, targetTypeName: string): ReactElementLike | null {
if (node == null) {
return null
}
if (Array.isArray(node)) {
for (const child of node) {
const found = findComponentByTypeName(child, targetTypeName)
if (found) {
return found
}
}
return null
}
const el = node as ReactElementLike
const typeName = typeof el.type === 'function' ? el.type.name : String(el.type)
if (typeName === targetTypeName) {
return el
}
if (el.props?.children) {
return findComponentByTypeName(el.props.children, targetTypeName)
}
return null
}
describe('TerminalAppearanceSection ghostty import wiring', () => {
beforeEach(() => {
mockStateValues.length = 0
@@ -421,6 +445,68 @@ describe('TerminalAppearanceSection ghostty import wiring', () => {
expect(findTerminalThemeCatalogSection(darkPhraseElement)?.props.preferredTarget).toBe('dark')
})
it('does not open advanced typography for primary terminal font searches', () => {
mockSettingsSearchQuery = 'font size'
const element = TerminalAppearanceSection({
settings: {} as never,
updateSettings: () => {},
systemPrefersDark: true,
terminalFontSuggestions: [],
ghostty: ghosttyMock,
warpThemes: warpThemesMock
})
expect(findComponentByTypeName(element, 'TerminalAdvancedTypographyControls')).toBeNull()
})
it('does not show primary typography chrome for unrelated terminal searches', () => {
mockSettingsSearchQuery = 'cursor opacity'
const element = TerminalAppearanceSection({
settings: {} as never,
updateSettings: () => {},
systemPrefersDark: true,
terminalFontSuggestions: [],
ghostty: ghosttyMock,
warpThemes: warpThemesMock
})
expect(findComponentByTypeName(element, 'TerminalFontSizeSetting')).toBeNull()
expect(findButtons(element).some((button) => button.text === 'Import from Ghostty')).toBe(false)
})
it('shows the Ghostty import button for Ghostty-only searches', () => {
mockSettingsSearchQuery = 'ghostty'
const element = TerminalAppearanceSection({
settings: {} as never,
updateSettings: () => {},
systemPrefersDark: true,
terminalFontSuggestions: [],
ghostty: ghosttyMock,
warpThemes: warpThemesMock
})
expect(findButtons(element).some((button) => button.text === 'Import from Ghostty')).toBe(true)
})
it('opens typography advanced inside the typography section for advanced searches', () => {
mockSettingsSearchQuery = 'line height'
const element = TerminalAppearanceSection({
settings: {} as never,
updateSettings: () => {},
systemPrefersDark: true,
terminalFontSuggestions: [],
ghostty: ghosttyMock,
warpThemes: warpThemesMock
})
expect(findComponentByTypeName(element, 'TerminalAdvancedTypographyControls')).not.toBeNull()
expect(findComponentByTypeName(element, 'TerminalFontSizeSetting')).not.toBeNull()
})
it('hides the theme import affordance on paired web clients', () => {
vi.stubGlobal('window', {
__ORCA_WEB_CLIENT__: true,
@@ -2,32 +2,42 @@ import { useState } from 'react'
import type { GlobalSettings } from '../../../../shared/types'
import {
matchesSettingsSearch,
normalizeSettingsSearchQuery,
scoreSettingsSearch,
type SettingsSearchEntry
} from './settings-search'
import { useAppStore } from '../../store'
import {
getTerminalAdvancedTypographySearchEntries,
getTerminalCursorSearchEntries,
getTerminalDarkThemeSearchEntries,
getTerminalGhosttyImportSearchEntries,
getTerminalLightThemeSearchEntries,
getTerminalPaneAppearanceSearchEntries,
getTerminalThemeTargetSearchEntries,
getTerminalTypographySearchEntries,
getTerminalWarpImportSearchEntries,
getTerminalWindowSearchEntries,
getTerminalYamlImportSearchEntries
getTerminalYamlImportSearchEntries,
getTerminalTypographySearchEntries,
getTerminalWindowSearchEntries
} from './terminal-search'
import { Button } from '../ui/button'
import { SettingsRow, SettingsSubsectionHeader } from './SettingsFormControls'
import { SearchableSetting } from './SearchableSetting'
import { FontAutocomplete } from './SettingsFormControls'
import { TerminalFontSizeSetting } from './TerminalFontSizeSetting'
import { TerminalAdvancedTypographyControls } from './TerminalAdvancedTypographyControls'
import { TerminalThemeCatalogSection } from './TerminalThemeSections'
import { TerminalWindowSection } from './TerminalWindowSection'
import { TerminalTypographyAppearanceSection } from './TerminalTypographyAppearanceSection'
import { TerminalCursorAppearanceSection } from './TerminalCursorAppearanceSection'
import { TerminalPaneAppearanceSection } from './TerminalPaneAppearanceSection'
import { AppearanceAdvancedDisclosure } from './AppearanceAdvancedDisclosure'
import { GhosttyImportModal } from './GhosttyImportModal'
import type { UseGhosttyImportReturn } from './useGhosttyImport'
import { WarpThemeImportModal } from './WarpThemeImportModal'
import type { UseWarpThemeImportReturn } from './useWarpThemeImport'
import { isWebClientLocation } from '@/hooks/useSettingsNavigationMetadata'
import ghosttyIcon from '../../../../../resources/ghostty.svg'
import { translate } from '@/i18n/i18n'
type TerminalAppearanceSectionProps = {
settings: GlobalSettings
@@ -36,6 +46,7 @@ type TerminalAppearanceSectionProps = {
terminalFontSuggestions: string[]
ghostty: UseGhosttyImportReturn
warpThemes: UseWarpThemeImportReturn
forceVisiblePrimary?: boolean
}
type TerminalThemeTarget = 'dark' | 'light'
@@ -64,88 +75,194 @@ export function TerminalAppearanceSection({
systemPrefersDark,
terminalFontSuggestions,
ghostty,
warpThemes
warpThemes,
forceVisiblePrimary = false
}: TerminalAppearanceSectionProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
const [themeSearch, setThemeSearch] = useState('')
const [previewFontFamily, setPreviewFontFamily] = useState<string | null>(null)
const showWarpThemeImport = !isWebClientLocation()
const darkThemeSearchEntries = getTerminalDarkThemeSearchEntries()
const lightThemeSearchEntries = getTerminalLightThemeSearchEntries()
const darkThemeSearchScore = scoreSettingsSearch(searchQuery, darkThemeSearchEntries)
const lightThemeSearchScore = scoreSettingsSearch(searchQuery, lightThemeSearchEntries)
const terminalTypographyEntries = getTerminalTypographySearchEntries()
const ghosttyImportEntries = getTerminalGhosttyImportSearchEntries()
const themeCatalogSearchEntries = [
...getTerminalThemeTargetSearchEntries(),
...darkThemeSearchEntries,
...lightThemeSearchEntries,
...(showWarpThemeImport
? [...getTerminalWarpImportSearchEntries(), ...getTerminalYamlImportSearchEntries()]
: [])
]
const darkThemeTargetScore = scoreThemeTargetIntent(searchQuery, darkThemeSearchEntries)
const lightThemeTargetScore = scoreThemeTargetIntent(searchQuery, lightThemeSearchEntries)
const darkThemeMatches = darkThemeSearchScore > 0
const lightThemeMatches = lightThemeSearchScore > 0
const themeTargetMatches = matchesSettingsSearch(
searchQuery,
getTerminalThemeTargetSearchEntries()
)
const themeImportMatches =
showWarpThemeImport &&
(matchesSettingsSearch(searchQuery, getTerminalWarpImportSearchEntries()) ||
matchesSettingsSearch(searchQuery, getTerminalYamlImportSearchEntries()))
const showTerminalThemeCatalog =
darkThemeMatches || lightThemeMatches || themeTargetMatches || themeImportMatches
const preferredThemeTarget = getPreferredThemeTarget(darkThemeTargetScore, lightThemeTargetScore)
const visibleSections = [
matchesSettingsSearch(searchQuery, getTerminalGhosttyImportSearchEntries()) ||
matchesSettingsSearch(searchQuery, getTerminalTypographySearchEntries()) ? (
<TerminalTypographyAppearanceSection
key="typography"
settings={settings}
updateSettings={updateSettings}
systemPrefersDark={systemPrefersDark}
terminalFontSuggestions={terminalFontSuggestions}
ghostty={ghostty}
previewFontFamily={previewFontFamily}
setPreviewFontFamily={setPreviewFontFamily}
/>
) : null,
matchesSettingsSearch(searchQuery, getTerminalCursorSearchEntries()) ? (
<TerminalCursorAppearanceSection
key="cursor"
settings={settings}
updateSettings={updateSettings}
/>
) : null,
matchesSettingsSearch(searchQuery, getTerminalPaneAppearanceSearchEntries()) ? (
<TerminalPaneAppearanceSection
key="pane-appearance"
settings={settings}
updateSettings={updateSettings}
/>
) : null,
matchesSettingsSearch(searchQuery, getTerminalWindowSearchEntries()) ? (
<TerminalWindowSection key="window" settings={settings} updateSettings={updateSettings} />
) : null,
showTerminalThemeCatalog ? (
<TerminalThemeCatalogSection
key={`theme-catalog-${preferredThemeTarget ?? 'manual'}`}
settings={settings}
systemPrefersDark={systemPrefersDark}
themeSearch={themeSearch}
setThemeSearch={setThemeSearch}
updateSettings={updateSettings}
previewFontFamily={previewFontFamily}
importedHighlightSignal={warpThemes.importSignal}
warpThemes={warpThemes}
showThemeImport={showWarpThemeImport}
preferredTarget={preferredThemeTarget}
/>
) : null
].filter(Boolean)
// Why: low-frequency knobs are force-opened during search; render each group
// only when its own search matches so an active query never leaves a dangling header.
const typographyMatches = matchesSettingsSearch(
searchQuery,
getTerminalAdvancedTypographySearchEntries()
)
const cursorMatches = matchesSettingsSearch(searchQuery, getTerminalCursorSearchEntries())
const paneMatches = matchesSettingsSearch(searchQuery, getTerminalPaneAppearanceSearchEntries())
const windowMatches = matchesSettingsSearch(searchQuery, getTerminalWindowSearchEntries())
const themeCatalogMatches = matchesSettingsSearch(searchQuery, themeCatalogSearchEntries)
const previewAdvancedMatches = cursorMatches || paneMatches || windowMatches
const showThemeCatalog = !isSearching || themeCatalogMatches || previewAdvancedMatches
const primaryTypographyMatches = matchesSettingsSearch(
searchQuery,
terminalTypographyEntries.slice(0, 2)
)
const ghosttyImportMatches = matchesSettingsSearch(searchQuery, ghosttyImportEntries)
const showPrimaryTypography =
!isSearching ||
forceVisiblePrimary ||
primaryTypographyMatches ||
typographyMatches ||
ghosttyImportMatches
const showGhosttyImport = !isSearching || forceVisiblePrimary || ghosttyImportMatches
const showTypographyAdvancedDisclosure = !isSearching || typographyMatches
return (
<div className="space-y-6">
{visibleSections.map((section, index) => (
<div key={index} className="space-y-6">
{index > 0 ? <div className="h-px bg-border/60" /> : null}
{section}
const advancedGroups = [
cursorMatches
? {
key: 'cursor',
node: (
<TerminalCursorAppearanceSection settings={settings} updateSettings={updateSettings} />
)
}
: null,
paneMatches
? {
key: 'pane',
node: (
<TerminalPaneAppearanceSection settings={settings} updateSettings={updateSettings} />
)
}
: null,
windowMatches
? {
key: 'window',
node: <TerminalWindowSection settings={settings} updateSettings={updateSettings} />
}
: null
].filter((group): group is { key: string; node: React.JSX.Element } => group !== null)
const showAdvancedDisclosure = !isSearching || advancedGroups.length > 0
const previewAdvancedContent = showAdvancedDisclosure ? (
<AppearanceAdvancedDisclosure
showTopBorder={false}
className="mt-0 pt-2"
contentClassName="ml-4 pt-4"
>
{advancedGroups.map((group, index) => (
<div
key={group.key}
className={index > 0 ? 'mt-2 border-t border-border/60 pt-4' : undefined}
>
{group.node}
</div>
))}
</AppearanceAdvancedDisclosure>
) : null
return (
<div className="space-y-5">
{/* Primary: font + theme + previews. The expanded section column is far
narrower than the xl breakpoint, so the preview grids inside the
theme catalog already stack full-width below their controls. */}
{showPrimaryTypography ? (
<section className="space-y-3 pt-2">
<SettingsSubsectionHeader
className="items-center"
title={translate(
'auto.components.settings.TerminalAppearanceSection.048aac8a64',
'Terminal Typography'
)}
action={
showGhosttyImport ? (
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => void ghostty.handleClick()}
>
<img src={ghosttyIcon} alt="" aria-hidden="true" className="size-4" />
{translate(
'auto.components.settings.TerminalAppearanceSection.855a76343a',
'Import from Ghostty'
)}
</Button>
) : null
}
/>
<div className="ml-4 divide-y divide-border/40 border-y border-border/40">
<TerminalFontSizeSetting
settings={settings}
updateSettings={updateSettings}
forceVisible={forceVisiblePrimary}
/>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalAppearanceSection.a408266e67',
'Font Family'
)}
description={terminalTypographyEntries[1]?.description}
keywords={
terminalTypographyEntries[1]?.keywords ?? ['terminal', 'typography', 'font']
}
forceVisible={forceVisiblePrimary}
>
<SettingsRow
label={translate(
'auto.components.settings.TerminalAppearanceSection.a408266e67',
'Font Family'
)}
control={
<FontAutocomplete
value={settings.terminalFontFamily}
suggestions={terminalFontSuggestions}
onChange={(value) => updateSettings({ terminalFontFamily: value })}
onPreviewFontFamily={setPreviewFontFamily}
/>
}
/>
</SearchableSetting>
</div>
{showTypographyAdvancedDisclosure ? (
<div className="ml-4">
<AppearanceAdvancedDisclosure showTopBorder={false} contentClassName="ml-4">
<TerminalAdvancedTypographyControls
settings={settings}
updateSettings={updateSettings}
/>
</AppearanceAdvancedDisclosure>
</div>
) : null}
</section>
) : null}
{showThemeCatalog ? (
<TerminalThemeCatalogSection
key={`theme-catalog-${preferredThemeTarget ?? 'manual'}`}
settings={settings}
systemPrefersDark={systemPrefersDark}
themeSearch={themeSearch}
setThemeSearch={setThemeSearch}
updateSettings={updateSettings}
previewFontFamily={previewFontFamily}
importedHighlightSignal={warpThemes.importSignal}
warpThemes={warpThemes}
showThemeImport={showWarpThemeImport}
preferredTarget={preferredThemeTarget}
advancedContent={previewAdvancedContent}
/>
) : null}
<GhosttyImportModal
open={ghostty.open}
onOpenChange={ghostty.handleOpenChange}
@@ -26,13 +26,9 @@ export function TerminalCursorAppearanceSection({
'auto.components.settings.TerminalAppearanceSection.abcb4dd019',
'Terminal Cursor'
)}
description={translate(
'auto.components.settings.TerminalAppearanceSection.d455f2ef4f',
'Default cursor appearance for Orca terminal panes.'
)}
/>
<div className="divide-y divide-border/40">
<div className="ml-4 divide-y divide-border/40">
<SearchableSetting
title={translate(
'auto.components.settings.TerminalAppearanceSection.db270cc9a9',
@@ -44,15 +40,12 @@ export function TerminalCursorAppearanceSection({
)}
keywords={['terminal', 'cursor', 'bar', 'block', 'underline']}
>
{/* Why: Bar/Block/Underline options convey the meaning; helper text pruned. */}
<SettingsRow
label={translate(
'auto.components.settings.TerminalAppearanceSection.db270cc9a9',
'Cursor Shape'
)}
description={translate(
'auto.components.settings.TerminalAppearanceSection.d455f2ef4f',
'Default cursor appearance for Orca terminal panes.'
)}
control={
<SettingsSegmentedControl
ariaLabel={translate(
@@ -105,10 +98,6 @@ export function TerminalCursorAppearanceSection({
'auto.components.settings.TerminalAppearanceSection.74736cc9b1',
'Blinking Cursor'
)}
description={translate(
'auto.components.settings.TerminalAppearanceSection.2de6b5a699',
'Uses the blinking variant of the selected cursor shape.'
)}
checked={settings.terminalCursorBlink}
onChange={() => updateSettings({ terminalCursorBlink: !settings.terminalCursorBlink })}
/>
@@ -130,10 +119,7 @@ export function TerminalCursorAppearanceSection({
'auto.components.settings.TerminalAppearanceSection.b9f1804422',
'Cursor Opacity'
)}
description={translate(
'auto.components.settings.TerminalAppearanceSection.04cdf85dec',
'Opacity of the terminal cursor.'
)}
description=""
value={settings.terminalCursorOpacity ?? 1}
defaultValue={1}
min={0}
@@ -8,10 +8,12 @@ import { translate } from '@/i18n/i18n'
export function TerminalFontSizeSetting({
settings,
updateSettings
updateSettings,
forceVisible = false
}: {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
forceVisible?: boolean
}): React.JSX.Element {
return (
<SearchableSetting
@@ -21,16 +23,15 @@ export function TerminalFontSizeSetting({
'Default terminal font size for new panes and live updates.'
)}
keywords={['terminal', 'typography', 'text size']}
forceVisible={forceVisible}
>
{/* Why: helper text dropped per the copy audit — "Font Size" + px control
is self-evident; the search index keeps the longer description. */}
<SettingsRow
label={translate(
'auto.components.settings.TerminalFontSizeSetting.a4a352b1e9',
'Font Size'
)}
description={translate(
'auto.components.settings.TerminalFontSizeSetting.0f4c92e595',
'Default terminal font size for new panes and live updates.'
)}
control={
<div className="flex items-center gap-2">
<Button
@@ -22,13 +22,9 @@ export function TerminalPaneAppearanceSection({
'auto.components.settings.TerminalAppearanceSection.e1a5c25555',
'Terminal Panes'
)}
description={translate(
'auto.components.settings.TerminalAppearanceSection.1b79379d4f',
'Control inactive pane dimming and split divider thickness.'
)}
/>
<div className="divide-y divide-border/40">
<div className="ml-4 divide-y divide-border/40">
<SearchableSetting
title={translate(
'auto.components.settings.TerminalAppearanceSection.a6fdd6a3b1',
@@ -45,9 +41,10 @@ export function TerminalPaneAppearanceSection({
'auto.components.settings.TerminalAppearanceSection.a6fdd6a3b1',
'Inactive Pane Opacity'
)}
// Why: clarify which panes get dimmed; tightened per the copy audit.
description={translate(
'auto.components.settings.TerminalAppearanceSection.db632cb50e',
'Opacity applied to panes that are not currently active.'
'auto.components.settings.TerminalAppearanceSection.dimUnfocusedPanes',
'Dim unfocused panes.'
)}
value={paneStyleOptions.inactivePaneOpacity}
defaultValue={0.8}
@@ -78,10 +75,7 @@ export function TerminalPaneAppearanceSection({
'auto.components.settings.TerminalAppearanceSection.f27a99978d',
'Divider Thickness'
)}
description={translate(
'auto.components.settings.TerminalAppearanceSection.a14a427ae4',
'Thickness of the pane divider line.'
)}
description=""
value={paneStyleOptions.dividerThicknessPx}
defaultValue={1}
min={1}
@@ -1,4 +1,4 @@
import { useState, type Dispatch, type SetStateAction } from 'react'
import { useState, type Dispatch, type ReactNode, type SetStateAction } from 'react'
import type { GlobalSettings } from '../../../../shared/types'
import {
ColorField,
@@ -29,6 +29,7 @@ type TerminalThemeCatalogSectionProps = {
warpThemes: UseWarpThemeImportReturn
showThemeImport: boolean
preferredTarget?: TerminalThemeTarget
advancedContent?: ReactNode
}
export function TerminalThemeCatalogSection({
@@ -41,7 +42,8 @@ export function TerminalThemeCatalogSection({
importedHighlightSignal,
warpThemes,
showThemeImport,
preferredTarget
preferredTarget,
advancedContent
}: TerminalThemeCatalogSectionProps): React.JSX.Element {
const [target, setTarget] = useState<TerminalThemeTarget>(preferredTarget ?? 'dark')
const themeOptions = getAvailableTerminalThemeOptions(settings)
@@ -78,25 +80,23 @@ export function TerminalThemeCatalogSection({
return (
<section className="space-y-5">
<SettingsSubsectionHeader
className="items-center"
title={translate(
'auto.components.settings.TerminalThemeSections.catalog_title',
'Terminal Themes'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.catalog_description',
'Choose terminal themes and divider colors for dark and light mode.'
)}
action={
showThemeImport ? (
<div className="flex flex-wrap items-center justify-end gap-2">
<WarpThemeImportButton warpThemes={warpThemes} />
<YamlThemeImportButton warpThemes={warpThemes} />
</div>
) : null
}
/>
{showThemeImport ? (
<div className="flex flex-wrap items-center gap-2">
<WarpThemeImportButton warpThemes={warpThemes} />
<YamlThemeImportButton warpThemes={warpThemes} />
</div>
) : null}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div>
<div className="ml-4 grid gap-4">
<div className={advancedContent ? 'border-b border-border/40' : undefined}>
<div className="space-y-3">
<SearchableSetting
title={translate(
@@ -237,6 +237,8 @@ export function TerminalThemeCatalogSection({
</div>
</div>
{advancedContent ? <div className="-mt-4">{advancedContent}</div> : null}
<TerminalSettingsPreview
title={
isLightTarget
@@ -59,132 +59,126 @@ export function TerminalWindowSection({
</p>
</div>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.ea7b1a158e',
'Background Opacity'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.03acb60aa0',
'Controls the transparency of the terminal background.'
)}
keywords={['opacity', 'transparency', 'background', 'alpha']}
>
<NumberField
label={translate(
<div className="ml-4 space-y-4">
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.ea7b1a158e',
'Background Opacity'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.809f37738d',
'Controls the transparency of the terminal background. 1 is fully opaque, 0 is fully transparent.'
'auto.components.settings.TerminalWindowSection.03acb60aa0',
'Controls the transparency of the terminal background.'
)}
value={settings.terminalBackgroundOpacity ?? 1}
defaultValue={1}
min={0}
max={1}
step={0.05}
suffix="0 to 1"
onChange={(value) =>
updateSettings({ terminalBackgroundOpacity: clampNumber(value, 0, 1) })
}
/>
</SearchableSetting>
keywords={['opacity', 'transparency', 'background', 'alpha']}
>
<NumberField
label={translate(
'auto.components.settings.TerminalWindowSection.ea7b1a158e',
'Background Opacity'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.809f37738d',
'Controls the transparency of the terminal background. 1 is fully opaque, 0 is fully transparent.'
)}
value={settings.terminalBackgroundOpacity ?? 1}
defaultValue={1}
min={0}
max={1}
step={0.05}
suffix="0 to 1"
onChange={(value) =>
updateSettings({ terminalBackgroundOpacity: clampNumber(value, 0, 1) })
}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.2b82242f43',
'Window Blur'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.97950bb087',
'Apply background blur to the terminal window. Requires restart.'
)}
keywords={['window', 'blur', 'background', 'transparency', 'vibrancy']}
className="space-y-3 py-2"
>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label>
{translate(
'auto.components.settings.TerminalWindowSection.2b82242f43',
'Window Blur'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.TerminalWindowSection.97950bb087',
'Apply background blur to the terminal window. Requires restart.'
)}
</p>
</div>
<button
role="switch"
aria-checked={settings.windowBackgroundBlur ?? false}
onClick={() => updateSettings({ windowBackgroundBlur: !settings.windowBackgroundBlur })}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
(settings.windowBackgroundBlur ?? false) ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
(settings.windowBackgroundBlur ?? false) ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
{blurPendingRestart ? (
<div className="flex items-center justify-between gap-3 rounded-md border border-yellow-500/50 bg-yellow-500/10 px-3 py-2.5">
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium text-yellow-700 dark:text-yellow-300">
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.2b82242f43',
'Window Blur'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.97950bb087',
'Apply background blur to the terminal window. Requires restart.'
)}
keywords={['window', 'blur', 'background', 'transparency', 'vibrancy']}
className="space-y-3 py-2"
>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label>
{translate(
'auto.components.settings.TerminalWindowSection.c65bb9ce63',
'Restart required'
'auto.components.settings.TerminalWindowSection.2b82242f43',
'Window Blur'
)}
</p>
</Label>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.TerminalWindowSection.53ce336e15',
'Restart Orca to apply the window blur change.'
'auto.components.settings.TerminalWindowSection.97950bb087',
'Apply background blur to the terminal window. Requires restart.'
)}
</p>
</div>
<Button
size="sm"
variant="default"
className="shrink-0 gap-1.5"
disabled={relaunchingBlur}
onClick={() => void handleRelaunch()}
<button
role="switch"
aria-checked={settings.windowBackgroundBlur ?? false}
onClick={() =>
updateSettings({ windowBackgroundBlur: !settings.windowBackgroundBlur })
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
(settings.windowBackgroundBlur ?? false)
? 'bg-foreground'
: 'bg-muted-foreground/30'
}`}
>
<RotateCw className={`size-3 ${relaunchingBlur ? 'animate-spin' : ''}`} />
{relaunchingBlur
? translate(
'auto.components.settings.TerminalWindowSection.907131d741',
'Restarting…'
)
: translate(
'auto.components.settings.TerminalWindowSection.8abdab9f7c',
'Restart now'
)}
</Button>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
(settings.windowBackgroundBlur ?? false) ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
) : null}
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.36b8402015',
'Horizontal Padding'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.25e2f8e8e1',
'Horizontal padding around the terminal grid in pixels.'
)}
keywords={['padding', 'horizontal', 'spacing', 'margin']}
>
<NumberField
label={translate(
{blurPendingRestart ? (
<div className="flex items-center justify-between gap-3 rounded-md border border-yellow-500/50 bg-yellow-500/10 px-3 py-2.5">
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium text-yellow-700 dark:text-yellow-300">
{translate(
'auto.components.settings.TerminalWindowSection.c65bb9ce63',
'Restart required'
)}
</p>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.TerminalWindowSection.53ce336e15',
'Restart Orca to apply the window blur change.'
)}
</p>
</div>
<Button
size="sm"
variant="default"
className="shrink-0 gap-1.5"
disabled={relaunchingBlur}
onClick={() => void handleRelaunch()}
>
<RotateCw className={`size-3 ${relaunchingBlur ? 'animate-spin' : ''}`} />
{relaunchingBlur
? translate(
'auto.components.settings.TerminalWindowSection.907131d741',
'Restarting…'
)
: translate(
'auto.components.settings.TerminalWindowSection.8abdab9f7c',
'Restart now'
)}
</Button>
</div>
) : null}
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.36b8402015',
'Horizontal Padding'
)}
@@ -192,29 +186,26 @@ export function TerminalWindowSection({
'auto.components.settings.TerminalWindowSection.25e2f8e8e1',
'Horizontal padding around the terminal grid in pixels.'
)}
value={settings.terminalPaddingX ?? 4}
defaultValue={4}
min={0}
max={512}
step={1}
suffix="px"
onChange={(value) => updateSettings({ terminalPaddingX: Math.max(0, value) })}
/>
</SearchableSetting>
keywords={['padding', 'horizontal', 'spacing', 'margin']}
>
<NumberField
label={translate(
'auto.components.settings.TerminalWindowSection.36b8402015',
'Horizontal Padding'
)}
description=""
value={settings.terminalPaddingX ?? 4}
defaultValue={4}
min={0}
max={512}
step={1}
suffix="px"
onChange={(value) => updateSettings({ terminalPaddingX: Math.max(0, value) })}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.1afcc1d973',
'Vertical Padding'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.1846f6ee6a',
'Vertical padding around the terminal grid in pixels.'
)}
keywords={['padding', 'vertical', 'spacing', 'margin']}
>
<NumberField
label={translate(
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.1afcc1d973',
'Vertical Padding'
)}
@@ -222,133 +213,140 @@ export function TerminalWindowSection({
'auto.components.settings.TerminalWindowSection.1846f6ee6a',
'Vertical padding around the terminal grid in pixels.'
)}
value={settings.terminalPaddingY ?? 4}
defaultValue={4}
min={0}
max={512}
step={1}
suffix="px"
onChange={(value) => updateSettings({ terminalPaddingY: Math.max(0, value) })}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.3530908ef9',
'Hide Mouse While Typing'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.1d1920dc8a',
'Hide the mouse cursor when typing in the terminal.'
)}
keywords={['mouse', 'hide', 'typing', 'cursor']}
className="flex items-center justify-between gap-4 py-2"
>
<div className="space-y-0.5">
<Label>
{translate(
'auto.components.settings.TerminalWindowSection.3530908ef9',
'Hide Mouse While Typing'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.TerminalWindowSection.1d1920dc8a',
'Hide the mouse cursor when typing in the terminal.'
)}
</p>
</div>
<button
role="switch"
aria-checked={settings.terminalMouseHideWhileTyping ?? false}
onClick={() =>
updateSettings({
terminalMouseHideWhileTyping: !settings.terminalMouseHideWhileTyping
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
(settings.terminalMouseHideWhileTyping ?? false)
? 'bg-foreground'
: 'bg-muted-foreground/30'
}`}
keywords={['padding', 'vertical', 'spacing', 'margin']}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
(settings.terminalMouseHideWhileTyping ?? false) ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.63f8d9336e',
'Color Overrides'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.e86e09b5c7',
'Override individual terminal colors.'
)}
keywords={['color', 'override', 'ansi', 'palette', 'theme']}
className="space-y-3"
>
<div className="space-y-2">
<button
onClick={() => setColorOverridesExpanded((prev) => !prev)}
className="flex items-center gap-2 text-sm font-medium"
>
<span className={`transition-transform ${colorOverridesExpanded ? 'rotate-90' : ''}`}>
▶
</span>
{translate(
'auto.components.settings.TerminalWindowSection.63f8d9336e',
'Color Overrides'
<NumberField
label={translate(
'auto.components.settings.TerminalWindowSection.1afcc1d973',
'Vertical Padding'
)}
</button>
<div
className={`grid overflow-hidden transition-all duration-300 ease-out ${
colorOverridesExpanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
description=""
value={settings.terminalPaddingY ?? 4}
defaultValue={4}
min={0}
max={512}
step={1}
suffix="px"
onChange={(value) => updateSettings({ terminalPaddingY: Math.max(0, value) })}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.3530908ef9',
'Hide Mouse While Typing'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.1d1920dc8a',
'Hide the mouse cursor when typing in the terminal.'
)}
keywords={['mouse', 'hide', 'typing', 'cursor']}
className="flex items-center justify-between gap-4 py-2"
>
<div className="space-y-0.5">
{/* Why: helper text dropped per copy audit — near-verbatim restatement
of the label; the search index keeps the longer phrasing. */}
<Label>
{translate(
'auto.components.settings.TerminalWindowSection.3530908ef9',
'Hide Mouse While Typing'
)}
</Label>
</div>
<button
role="switch"
aria-checked={settings.terminalMouseHideWhileTyping ?? false}
onClick={() =>
updateSettings({
terminalMouseHideWhileTyping: !settings.terminalMouseHideWhileTyping
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
(settings.terminalMouseHideWhileTyping ?? false)
? 'bg-foreground'
: 'bg-muted-foreground/30'
}`}
>
<div className="min-h-0 space-y-4">
{COLOR_OVERRIDE_GROUPS.map((group) => (
<div key={group.label} className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground">{group.label}</p>
<div className="grid gap-2 sm:grid-cols-2">
{group.keys.map((item) => (
<ColorField
key={item.key}
label={item.label}
description={item.description}
value={settings.terminalColorOverrides?.[item.key] ?? ''}
fallback=""
onChange={(value) =>
updateSettings({
terminalColorOverrides: {
...settings.terminalColorOverrides,
[item.key]: value || undefined
}
})
}
/>
))}
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
(settings.terminalMouseHideWhileTyping ?? false)
? 'translate-x-4'
: 'translate-x-0.5'
}`}
/>
</button>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.63f8d9336e',
'Color Overrides'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.e86e09b5c7',
'Override individual terminal colors.'
)}
keywords={['color', 'override', 'ansi', 'palette', 'theme']}
className="space-y-3"
>
<div className="space-y-2">
<button
onClick={() => setColorOverridesExpanded((prev) => !prev)}
className="flex items-center gap-2 text-sm font-medium"
>
<span className={`transition-transform ${colorOverridesExpanded ? 'rotate-90' : ''}`}>
▶
</span>
{translate(
'auto.components.settings.TerminalWindowSection.63f8d9336e',
'Color Overrides'
)}
</button>
<div
className={`grid overflow-hidden transition-all duration-300 ease-out ${
colorOverridesExpanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
}`}
>
<div className="min-h-0 space-y-4">
{COLOR_OVERRIDE_GROUPS.map((group) => (
<div key={group.label} className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground">{group.label}</p>
<div className="grid gap-2 sm:grid-cols-2">
{group.keys.map((item) => (
<ColorField
key={item.key}
label={item.label}
description={item.description}
value={settings.terminalColorOverrides?.[item.key] ?? ''}
fallback=""
onChange={(value) =>
updateSettings({
terminalColorOverrides: {
...settings.terminalColorOverrides,
[item.key]: value || undefined
}
})
}
/>
))}
</div>
</div>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => updateSettings({ terminalColorOverrides: undefined })}
>
{translate(
'auto.components.settings.TerminalWindowSection.03c855d15f',
'Reset all color overrides'
)}
</Button>
))}
<Button
variant="outline"
size="sm"
onClick={() => updateSettings({ terminalColorOverrides: undefined })}
>
{translate(
'auto.components.settings.TerminalWindowSection.03c855d15f',
'Reset all color overrides'
)}
</Button>
</div>
</div>
</div>
</div>
</SearchableSetting>
</SearchableSetting>
</div>
</section>
)
}
@@ -245,6 +245,25 @@ export function getSystemTrayEntries(options: SystemTraySearchOptions = {}): Set
return shouldShowSystemTrayEntries(options) ? getSystemTrayEntryCatalog() : []
}
const getAppearanceSectionEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [
{
title: translate('auto.components.settings.AppearancePane.interfaceTitle', 'Interface')
},
{
title: translate('auto.components.settings.AppearancePane.terminalTitle', 'Terminal')
},
{
title: translate(
'auto.components.settings.AppearancePane.windowSidebarTitle',
'Window & Sidebar'
),
description: translate(
'auto.components.settings.AppearancePane.windowSidebarSummary',
'Sidebar, status bar, and file explorer'
)
}
])
type AppearancePaneSearchOptions = {
showWarpImport?: boolean
showSystemTray?: boolean
@@ -254,6 +273,7 @@ function buildAppearancePaneSearchEntries(
options: AppearancePaneSearchOptions
): SettingsSearchEntry[] {
return [
...getAppearanceSectionEntries(),
...getThemeEntries(),
...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []),
...getTypographyEntries(),
@@ -43,7 +43,7 @@ export const getWorkspaceCardLayoutEntry = createLocalizedCatalog(
),
description: translate(
'auto.components.settings.appearance.search.workspaceCardLayout.description',
'Switch between compact and detailed workspace cards from the workspace sidebar options menu.'
'Workspace cards can use compact or detailed layouts.'
),
keywords: [
...translateSearchKeyword(
@@ -117,6 +117,10 @@ export function getRepositoryGitHooksSearchEntries(repo: Repo): SettingsSearchEn
'auto.components.settings.repository.search.f9d84b7971',
'setup run policy'
),
...translateSearchKeyword(
'auto.components.settings.repository.search.waitForSetupBeforeAgent',
'wait for setup before starting agent'
),
...translateSearchKeyword('auto.components.settings.repository.search.80c490b012', 'ask'),
...translateSearchKeyword(
'auto.components.settings.repository.search.a69c5cbe90',
@@ -180,6 +180,8 @@ describe('getTerminalPaneSearchEntries', () => {
expect(getSidebarEntries()).toContainEqual(entry)
expect(getAppearancePaneSearchEntries()).toContainEqual(entry)
expect(entry.description).toBe('Workspace cards can use compact or detailed layouts.')
expect(entry.description).not.toContain('options menu')
})
it.each(['compact', 'compact display', 'workspace cards', 'sidebar', 'card layout'])(
@@ -34,6 +34,7 @@ import {
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
export {
getTerminalAdvancedTypographySearchEntries,
getTerminalTypographySearchEntries,
getTerminalRenderingSearchEntries,
getTerminalCursorSearchEntries
@@ -2,7 +2,7 @@ import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
export const getTerminalTypographySearchEntries = createLocalizedCatalog(() => [
const getTerminalTypographySearchEntryCatalog = createLocalizedCatalog(() => [
{
title: translate('auto.components.settings.terminal.search.5930244899', 'Font Size'),
description: translate(
@@ -100,6 +100,14 @@ export const getTerminalTypographySearchEntries = createLocalizedCatalog(() => [
}
])
export const getTerminalTypographySearchEntries = createLocalizedCatalog(() => [
...getTerminalTypographySearchEntryCatalog()
])
export const getTerminalAdvancedTypographySearchEntries = createLocalizedCatalog(() =>
getTerminalTypographySearchEntryCatalog().slice(2)
)
export const getTerminalRenderingSearchEntries = createLocalizedCatalog(() => [
{
title: translate('auto.components.settings.terminal.search.13a2502dfc', 'GPU Acceleration'),
@@ -1,5 +1,5 @@
import React, { useState } from 'react'
import { ChevronRight, EyeOff, X } from 'lucide-react'
import { ChevronRight, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@@ -143,7 +143,6 @@ export default function ImportedWorktreesVisibilityLine({
aria-hidden="true"
/>
</Button>
<EyeOff className="size-3 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{lineText}</span>
{onKeepHidden ? (
<Tooltip>
@@ -0,0 +1,86 @@
// @vitest-environment happy-dom
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import NewExternalWorktreesInboxLine from './NewExternalWorktreesInboxLine'
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => (
<span data-testid="tooltip-content">{children}</span>
)
}))
const roots: Root[] = []
async function renderLine(): Promise<HTMLDivElement> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
await act(async () => {
root.render(
<NewExternalWorktreesInboxLine
repoDisplayName="orca"
inboxWorktrees={[
{
id: 'external-1',
displayName: 'payments-refactor',
branch: 'refs/heads/payments-refactor',
path: '/worktrees/orca/payments-refactor'
}
]}
pending={false}
error={null}
onImportWorktree={vi.fn()}
onKeepHidden={vi.fn()}
onImportAll={vi.fn()}
onSuppress={vi.fn()}
/>
)
})
return container
}
describe('NewExternalWorktreesInboxLine', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
roots.splice(0).forEach((root) => {
act(() => root.unmount())
})
document.body.replaceChildren()
vi.clearAllMocks()
})
it('keeps suppress as a hover-revealed header icon instead of expanded text action', async () => {
const container = await renderLine()
const suppressButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Hide external worktrees permanently for orca"]'
)
expect(suppressButton).not.toBeNull()
expect(suppressButton?.className).toContain('can-hover:group-hover:opacity-100')
expect(container.textContent).toContain("Don't show again")
const expandButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Expand new externally-created worktrees for orca"]'
)
await act(async () => {
expandButton?.click()
})
expect(container.textContent).toContain('payments-refactor')
const textButtons = [...container.querySelectorAll('button')].filter(
(button) => button.textContent === "Don't show again"
)
expect(textButtons).toHaveLength(0)
})
})
@@ -0,0 +1,207 @@
import React, { useState } from 'react'
import { ChevronRight, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { NewExternalWorktreeInboxPreview } from './new-external-worktrees-inbox-candidates'
type NewExternalWorktreesInboxLineProps = {
repoDisplayName: string
inboxWorktrees: readonly NewExternalWorktreeInboxPreview[]
pending: boolean
error: string | null
onImportWorktree?: (worktreeId: string) => void
onKeepHidden?: () => void
onImportAll?: () => void
onSuppress?: () => void
className?: string
}
export default function NewExternalWorktreesInboxLine({
repoDisplayName,
inboxWorktrees,
pending,
error,
onImportWorktree,
onKeepHidden,
onImportAll,
onSuppress,
className
}: NewExternalWorktreesInboxLineProps): React.JSX.Element | null {
const [isExpanded, setIsExpanded] = useState(false)
const inboxCount = inboxWorktrees.length
const suppressLabel = translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.c3e8a1f4b2',
"Don't show again"
)
const suppressAriaLabel = translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.9f2d4c8b17',
'Hide external worktrees permanently for {{value0}}',
{ value0: repoDisplayName }
)
if (inboxCount === 0) {
return null
}
return (
<section
aria-busy={pending}
className={cn('mx-1 my-0.5 ml-3 text-worktree-sidebar-foreground', className)}
>
<div
className={cn(
'group flex min-h-7 min-w-0 items-center gap-1.5 rounded-md px-1.5 text-[11px] leading-none text-muted-foreground transition-colors',
'hover:bg-worktree-sidebar-accent hover:text-worktree-sidebar-accent-foreground'
)}
>
<Button
type="button"
variant="ghost"
size="icon-xs"
disabled={pending}
aria-expanded={isExpanded}
aria-label={
isExpanded
? translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.d9f7b2a14c',
'Collapse new externally-created worktrees for {{value0}}',
{ value0: repoDisplayName }
)
: translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.e2c4a8d91f',
'Expand new externally-created worktrees for {{value0}}',
{ value0: repoDisplayName }
)
}
onClick={() => setIsExpanded((value) => !value)}
className="shrink-0 rounded-[4px] text-muted-foreground hover:bg-worktree-sidebar-accent hover:text-worktree-sidebar-accent-foreground"
>
<ChevronRight
className={cn('size-3 transition-transform', isExpanded && 'rotate-90')}
aria-hidden="true"
/>
</Button>
<span className="min-w-0 flex-1 truncate">
{translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.7c4e9b2a81',
'New externally-created worktrees'
)}
</span>
<span className="relative inline-grid size-6 shrink-0 place-items-center">
<span
className={cn(
'inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full border border-border px-1.5 text-[10px] font-medium leading-none text-muted-foreground transition-opacity',
onSuppress &&
'can-hover:group-hover:opacity-0 can-hover:group-focus-within:opacity-0 [@media(hover:none)]:opacity-0'
)}
>
{inboxCount}
</span>
{onSuppress ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
disabled={pending}
aria-label={suppressAriaLabel}
onClick={onSuppress}
className="absolute inset-0 text-muted-foreground hover:bg-worktree-sidebar-accent hover:text-worktree-sidebar-accent-foreground can-hover:pointer-events-none can-hover:opacity-0 can-hover:group-hover:pointer-events-auto can-hover:group-hover:opacity-100 can-hover:group-focus-within:pointer-events-auto can-hover:group-focus-within:opacity-100"
>
<X className="size-3" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{suppressLabel}
</TooltipContent>
</Tooltip>
) : null}
</span>
</div>
{isExpanded ? (
<div className="ml-4 mt-0.5 border-l border-worktree-sidebar-border pb-1 pl-2">
<p className="px-1.5 py-1 text-[10px] leading-4 text-muted-foreground">
{translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.4d7a1c9e53',
'These worktrees were created outside of Orca.'
)}
</p>
<ul className="grid gap-0.5">
{inboxWorktrees.map((worktree) => (
<li
key={worktree.id ?? worktree.path ?? worktree.displayName}
className="flex min-h-7 min-w-0 items-center gap-2 rounded-md px-1.5 py-1 text-xs hover:bg-worktree-sidebar-accent"
>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{worktree.displayName}</div>
{worktree.path ? (
<div className="truncate font-mono text-[10px] text-muted-foreground">
{worktree.path}
</div>
) : null}
</div>
{onImportWorktree && worktree.id ? (
<Button
type="button"
variant="outline"
size="xs"
disabled={pending}
onClick={() => onImportWorktree(worktree.id!)}
>
{translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.8b3f2e1d74',
'Import'
)}
</Button>
) : null}
</li>
))}
</ul>
<div className="grid gap-1 px-1.5 pb-1 pt-1">
<div className="flex flex-wrap gap-1.5">
{onKeepHidden ? (
<Button
type="button"
variant="outline"
size="xs"
disabled={pending}
onClick={onKeepHidden}
>
{translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.1c9e7a4b28',
'Keep hidden'
)}
</Button>
) : null}
{onImportAll ? (
<Button
type="button"
variant="default"
size="xs"
disabled={pending}
onClick={onImportAll}
>
{translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.6f2d8c1e95',
'Import all'
)}
</Button>
) : null}
</div>
</div>
</div>
) : null}
{error ? (
<p className="px-1.5 pb-1 pt-0.5 text-[11px] leading-4 text-destructive" role="alert">
{error}
</p>
) : null}
</section>
)
}
@@ -0,0 +1,88 @@
import React from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { translate } from '@/i18n/i18n'
type SuppressExternalWorktreeInboxDialogProps = {
open: boolean
repoDisplayName: string
pending: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
onOpenRecovery: () => void
}
export default function SuppressExternalWorktreeInboxDialog({
open,
repoDisplayName,
pending,
onOpenChange,
onConfirm,
onOpenRecovery
}: SuppressExternalWorktreeInboxDialogProps): React.JSX.Element {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{translate(
'auto.components.sidebar.SuppressExternalWorktreeInboxDialog.a4c2d8f1b0',
'Hide external worktrees?'
)}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.sidebar.SuppressExternalWorktreeInboxDialog.6e91b3c4d2',
'External worktrees will not be shown in the sidebar or this list anymore for {{value0}}, including ones created later.',
{ value0: repoDisplayName }
)}
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border border-border bg-muted/30 px-3 py-2 text-sm text-muted-foreground">
{translate(
'auto.components.sidebar.SuppressExternalWorktreeInboxDialog.1f8a5d9e73',
'You can turn this back on later from project settings.'
)}
<button
type="button"
className="mt-1 block font-medium text-foreground underline underline-offset-2"
onClick={onOpenRecovery}
>
{translate(
'auto.components.sidebar.SuppressExternalWorktreeInboxDialog.8c0b2e7a41',
'Open Non-Orca worktrees settings'
)}
</button>
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
disabled={pending}
onClick={() => onOpenChange(false)}
>
{translate(
'auto.components.sidebar.SuppressExternalWorktreeInboxDialog.5d1c9f0a82',
'Cancel'
)}
</Button>
<Button type="button" disabled={pending} onClick={onConfirm}>
{translate(
'auto.components.sidebar.SuppressExternalWorktreeInboxDialog.3b7e4a1c96',
'Hide external worktrees'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -33,13 +33,17 @@ function makeFolderWorkspacePathStatusMockState(): Record<string, unknown> {
}
vi.mock('@/store', () => {
const getMockState = (): Record<string, unknown> => ({
detectedWorktreesByRepo: {},
...mockStore.state
})
const useAppStore = ((selector: (state: Record<string, unknown>) => unknown) =>
selector(mockStore.state)) as ((
selector(getMockState())) as ((
selector: (state: Record<string, unknown>) => unknown
) => unknown) & {
getState: () => Record<string, unknown>
}
useAppStore.getState = () => mockStore.state
useAppStore.getState = () => getMockState()
return { useAppStore }
})
@@ -215,13 +215,29 @@ import {
import { RepoIconGlyph } from '@/components/repo/repo-icon'
import { RepoForkIndicator } from '@/components/repo/repo-fork-indicator'
import ImportedWorktreesVisibilityLine from './ImportedWorktreesVisibilityLine'
import NewExternalWorktreesInboxLine from './NewExternalWorktreesInboxLine'
import SuppressExternalWorktreeInboxDialog from './SuppressExternalWorktreeInboxDialog'
import {
keepImportedWorktreesHiddenCard,
IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR,
showImportedWorktreesCard,
type ImportedWorktreeCardActionState
} from './imported-worktrees-card-actions'
import {
importNewExternalWorktreeInboxPaths,
keepNewExternalWorktreeInboxHidden,
suppressNewExternalWorktreeInbox,
type NewExternalWorktreesInboxActionState
} from './new-external-worktrees-inbox-actions'
import { getEligibleWorktreeParents } from './worktree-parent-candidates'
import { buildImportedWorktreesCardCandidates } from './imported-worktrees-card-candidates'
import {
buildImportedWorktreesCardCandidates,
getHiddenImportedWorktrees
} from './imported-worktrees-card-candidates'
import {
buildNewExternalWorktreesInboxCandidates,
toNewExternalWorktreeInboxPreview
} from './new-external-worktrees-inbox-candidates'
import {
WORKTREE_SECTION_HEADER_PADDING_LEFT,
LINEAGE_CHILDREN_INLINE_OFFSET,
@@ -470,6 +486,9 @@ function getRenderRowSidebarKey(row: RenderRow): string | null {
if (row.type === 'imported-worktrees-card') {
return row.key
}
if (row.type === 'new-external-worktrees-inbox') {
return row.key
}
return null
}
@@ -589,6 +608,11 @@ type VirtualizedWorktreeViewportProps = {
handleShowImportedWorktrees: (projectId: string) => void
handleKeepImportedWorktreesHidden: (projectId: string) => void
importedWorktreeCardActionState: ReadonlyMap<string, ImportedWorktreeCardActionState>
handleImportNewExternalWorktree: (projectId: string, worktreeId: string) => void
handleImportAllNewExternalWorktrees: (projectId: string) => void
handleKeepNewExternalWorktreeInboxHidden: (projectId: string) => void
handleOpenSuppressExternalWorktreeInbox: (projectId: string) => void
newExternalWorktreeInboxActionState: ReadonlyMap<string, NewExternalWorktreesInboxActionState>
handleRemoveProject: (repo: Repo) => void
handleCreateGroupFromRepo: (repo: Repo) => void
handleMoveProjectToGroup: (repo: Repo, groupId: string) => void
@@ -1126,6 +1150,9 @@ export function getRenderRowKey(row: RenderRow): string {
if (row.type === 'imported-worktrees-card') {
return `imported:${row.key}`
}
if (row.type === 'new-external-worktrees-inbox') {
return `inbox:${row.key}`
}
if (row.type === 'pending-creation') {
return `pending:${row.creationId}`
}
@@ -1148,6 +1175,7 @@ export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup
if (
row.type === 'host-header' ||
row.type === 'imported-worktrees-card' ||
row.type === 'new-external-worktrees-inbox' ||
row.type === 'pending-creation' ||
row.type === 'folder-workspace'
) {
@@ -1219,6 +1247,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
handleShowImportedWorktrees,
handleKeepImportedWorktreesHidden,
importedWorktreeCardActionState,
handleImportNewExternalWorktree,
handleImportAllNewExternalWorktrees,
handleKeepNewExternalWorktreeInboxHidden,
handleOpenSuppressExternalWorktreeInbox,
newExternalWorktreeInboxActionState,
handleRemoveProject,
handleCreateGroupFromRepo,
handleMoveProjectToGroup,
@@ -2307,6 +2340,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
projectGroups,
new Set(),
new Map(),
new Map(),
[],
projectGrouping
).filter((r): r is Extract<Row, { type: 'item' }> => r.type === 'item')
@@ -4738,6 +4772,36 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
)
}
if (row.type === 'new-external-worktrees-inbox') {
const actionState = newExternalWorktreeInboxActionState.get(row.repo.id)
return (
<div
key={vItem.key}
role="presentation"
data-worktree-virtual-row
data-worktree-virtual-row-key={String(vItem.key)}
data-worktree-virtual-row-start={vItem.start}
data-index={vItem.index}
ref={measureVirtualRowElement}
className="absolute left-0 right-0 top-0"
style={{ transform: getVirtualRowTransform(vItem.start) }}
>
<NewExternalWorktreesInboxLine
repoDisplayName={row.repo.displayName}
inboxWorktrees={row.inboxWorktrees.map(toNewExternalWorktreeInboxPreview)}
pending={actionState?.pending ?? false}
error={actionState?.error ?? null}
onImportWorktree={(worktreeId) =>
handleImportNewExternalWorktree(row.repo.id, worktreeId)
}
onKeepHidden={() => handleKeepNewExternalWorktreeInboxHidden(row.repo.id)}
onImportAll={() => handleImportAllNewExternalWorktrees(row.repo.id)}
onSuppress={() => handleOpenSuppressExternalWorktreeInbox(row.repo.id)}
/>
</div>
)
}
if (row.type === 'pending-creation') {
return (
<div
@@ -5448,6 +5512,12 @@ const WorktreeList = React.memo(function WorktreeList({
const [importedWorktreeCardActionState, setImportedWorktreeCardActionState] = useState<
Map<string, ImportedWorktreeCardActionState>
>(new Map())
const [newExternalWorktreeInboxActionState, setNewExternalWorktreeInboxActionState] = useState<
Map<string, NewExternalWorktreesInboxActionState>
>(new Map())
const [suppressExternalWorktreeInboxRepoId, setSuppressExternalWorktreeInboxRepoId] = useState<
string | null
>(null)
const importedWorktreesByRepo = useMemo(() => {
const forceVisibleRepoIds = new Set(
[...importedWorktreeCardActionState.entries()]
@@ -5461,6 +5531,15 @@ const WorktreeList = React.memo(function WorktreeList({
forceVisibleRepoIds
})
}, [detectedWorktreesByRepo, filterRepoIds, importedWorktreeCardActionState, visibleReposForRows])
const newExternalWorktreesInboxByRepo = useMemo(
() =>
buildNewExternalWorktreesInboxCandidates({
repos: visibleReposForRows,
detectedWorktreesByRepo,
filterRepoIds
}),
[detectedWorktreesByRepo, filterRepoIds, visibleReposForRows]
)
const placeholderRepoIds = useMemo(() => {
return getEmptyProjectPlaceholderRepoIds({
groupBy,
@@ -5537,6 +5616,7 @@ const WorktreeList = React.memo(function WorktreeList({
visibleProjectGroupsForRows,
placeholderRepoIds,
importedWorktreesByRepo,
newExternalWorktreesInboxByRepo,
pendingCreations,
projectGrouping,
visibleFolderWorkspacesForRows,
@@ -5559,6 +5639,7 @@ const WorktreeList = React.memo(function WorktreeList({
visibleFolderWorkspacesForRows,
placeholderRepoIds,
importedWorktreesByRepo,
newExternalWorktreesInboxByRepo,
pendingCreations,
hostLabelById
]
@@ -5626,6 +5707,8 @@ const WorktreeList = React.memo(function WorktreeList({
keys.add(`pending:${row.creationId}`)
} else if (row.type === 'imported-worktrees-card') {
keys.add(row.key)
} else if (row.type === 'new-external-worktrees-inbox') {
keys.add(row.key)
}
}
return keys
@@ -5814,15 +5897,147 @@ const WorktreeList = React.memo(function WorktreeList({
const handleKeepImportedWorktreesHidden = useCallback(
async (projectId: string) => {
const repo = repos.find((candidate) => candidate.id === projectId)
let detected = detectedWorktreesByRepo[projectId]
// Why: baseline seeding depends on authoritative hidden paths; do not
// dismiss the initial prompt on a stale/non-authoritative snapshot.
if (detected?.authoritative !== true) {
const refreshed = await fetchWorktrees(projectId, { requireAuthoritative: true })
if (!refreshed) {
setImportedWorktreeCardState(projectId, {
pending: false,
error: IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR
})
return
}
detected = useAppStore.getState().detectedWorktreesByRepo[projectId]
}
if (detected?.authoritative !== true) {
setImportedWorktreeCardState(projectId, {
pending: false,
error: IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR
})
return
}
const hiddenWorktrees = getHiddenImportedWorktrees(detected)
await keepImportedWorktreesHiddenCard({
projectId,
updateRepo,
setCardState: setImportedWorktreeCardState
setCardState: setImportedWorktreeCardState,
hiddenWorktreePaths: hiddenWorktrees.map((worktree) => worktree.path),
existingBaselinePaths: repo?.externalWorktreeInboxBaselinePaths
})
},
[setImportedWorktreeCardState, updateRepo]
[detectedWorktreesByRepo, fetchWorktrees, repos, setImportedWorktreeCardState, updateRepo]
)
const setNewExternalWorktreeInboxState = useCallback(
(projectId: string, state: NewExternalWorktreesInboxActionState | null) => {
setNewExternalWorktreeInboxActionState((previous) => {
const next = new Map(previous)
if (state) {
next.set(projectId, state)
} else {
next.delete(projectId)
}
return next
})
},
[]
)
const getNewExternalWorktreeInboxActionArgs = useCallback(
(projectId: string, worktreePaths: readonly string[]) => {
const repo = repos.find((candidate) => candidate.id === projectId)
if (!repo) {
return null
}
return {
projectId,
repo,
worktreePaths,
updateRepo,
fetchWorktrees,
setInboxState: setNewExternalWorktreeInboxState
}
},
[fetchWorktrees, repos, setNewExternalWorktreeInboxState, updateRepo]
)
const handleImportNewExternalWorktree = useCallback(
async (projectId: string, worktreeId: string) => {
const inboxWorktrees = newExternalWorktreesInboxByRepo.get(projectId)?.inboxWorktrees ?? []
const worktree = inboxWorktrees.find((candidate) => candidate.id === worktreeId)
if (!worktree) {
return
}
const args = getNewExternalWorktreeInboxActionArgs(projectId, [worktree.path])
if (!args) {
return
}
await importNewExternalWorktreeInboxPaths(args)
},
[getNewExternalWorktreeInboxActionArgs, newExternalWorktreesInboxByRepo]
)
const handleImportAllNewExternalWorktrees = useCallback(
async (projectId: string) => {
const inboxWorktrees = newExternalWorktreesInboxByRepo.get(projectId)?.inboxWorktrees ?? []
const args = getNewExternalWorktreeInboxActionArgs(
projectId,
inboxWorktrees.map((worktree) => worktree.path)
)
if (!args) {
return
}
await importNewExternalWorktreeInboxPaths(args)
},
[getNewExternalWorktreeInboxActionArgs, newExternalWorktreesInboxByRepo]
)
const handleKeepNewExternalWorktreeInboxHidden = useCallback(
async (projectId: string) => {
const inboxWorktrees = newExternalWorktreesInboxByRepo.get(projectId)?.inboxWorktrees ?? []
const args = getNewExternalWorktreeInboxActionArgs(
projectId,
inboxWorktrees.map((worktree) => worktree.path)
)
if (!args) {
return
}
await keepNewExternalWorktreeInboxHidden(args)
},
[getNewExternalWorktreeInboxActionArgs, newExternalWorktreesInboxByRepo]
)
const handleOpenSuppressExternalWorktreeInbox = useCallback((projectId: string) => {
setSuppressExternalWorktreeInboxRepoId(projectId)
}, [])
const handleConfirmSuppressExternalWorktreeInbox = useCallback(async () => {
if (!suppressExternalWorktreeInboxRepoId) {
return
}
const projectId = suppressExternalWorktreeInboxRepoId
const inboxWorktrees = newExternalWorktreesInboxByRepo.get(projectId)?.inboxWorktrees ?? []
const args = getNewExternalWorktreeInboxActionArgs(
projectId,
inboxWorktrees.map((worktree) => worktree.path)
)
if (!args) {
setSuppressExternalWorktreeInboxRepoId(null)
return
}
const suppressed = await suppressNewExternalWorktreeInbox(args)
if (suppressed) {
setSuppressExternalWorktreeInboxRepoId(null)
}
}, [
getNewExternalWorktreeInboxActionArgs,
newExternalWorktreesInboxByRepo,
suppressExternalWorktreeInboxRepoId
])
const handleRemoveProject = useCallback(
(repo: Repo) => {
openModal('confirm-remove-folder', {
@@ -6371,6 +6586,37 @@ const WorktreeList = React.memo(function WorktreeList({
}}
onSubmit={handleSubmitProjectGroupName}
/>
<SuppressExternalWorktreeInboxDialog
open={suppressExternalWorktreeInboxRepoId !== null}
repoDisplayName={
suppressExternalWorktreeInboxRepoId
? (repos.find((repo) => repo.id === suppressExternalWorktreeInboxRepoId)?.displayName ??
'')
: ''
}
pending={
suppressExternalWorktreeInboxRepoId
? (newExternalWorktreeInboxActionState.get(suppressExternalWorktreeInboxRepoId)
?.pending ?? false)
: false
}
onOpenChange={(open) => {
if (!open) {
setSuppressExternalWorktreeInboxRepoId(null)
}
}}
onConfirm={() => {
void handleConfirmSuppressExternalWorktreeInbox()
}}
onOpenRecovery={() => {
if (!suppressExternalWorktreeInboxRepoId) {
return
}
const projectId = suppressExternalWorktreeInboxRepoId
setSuppressExternalWorktreeInboxRepoId(null)
handleOpenWorktreeVisibility(projectId)
}}
/>
<ProjectGroupDeleteDialog
open={projectGroupDeleteDialog !== null}
groupName={projectGroupDeleteDialog?.groupName ?? ''}
@@ -6404,6 +6650,11 @@ const WorktreeList = React.memo(function WorktreeList({
handleShowImportedWorktrees={handleShowImportedWorktrees}
handleKeepImportedWorktreesHidden={handleKeepImportedWorktreesHidden}
importedWorktreeCardActionState={importedWorktreeCardActionState}
handleImportNewExternalWorktree={handleImportNewExternalWorktree}
handleImportAllNewExternalWorktrees={handleImportAllNewExternalWorktrees}
handleKeepNewExternalWorktreeInboxHidden={handleKeepNewExternalWorktreeInboxHidden}
handleOpenSuppressExternalWorktreeInbox={handleOpenSuppressExternalWorktreeInbox}
newExternalWorktreeInboxActionState={newExternalWorktreeInboxActionState}
handleRemoveProject={handleRemoveProject}
handleCreateGroupFromRepo={handleCreateGroupFromRepo}
handleMoveProjectToGroup={handleMoveProjectToGroup}
@@ -49,7 +49,14 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
if (!repoId) {
return
}
await updateRepo(repoId, { externalWorktreeVisibility: showOther ? 'hide' : 'show' })
await updateRepo(repoId, {
externalWorktreeVisibility: showOther ? 'hide' : 'show',
// Why: showing hidden externals again should re-enable the inbox if the
// user previously opted out of discovery prompts for this repo.
// Why: null is the transport sentinel for clearing on remote runtime paths
// where `undefined` is stripped before persistence.
...(!showOther ? { externalWorktreeDiscoverySuppressedAt: null } : {})
})
await fetchWorktrees(repoId)
closeModal()
}, [closeModal, fetchWorktrees, repoId, showOther, updateRepo])
@@ -73,6 +73,7 @@ function getRowHostId(row: Row, defaultHostId: ExecutionHostId): ExecutionHostId
return getRepoHostId(row.repo, defaultHostId)
case 'pending-creation':
case 'imported-worktrees-card':
case 'new-external-worktrees-inbox':
return getRepoHostId(row.repo, defaultHostId)
case 'folder-workspace':
return getFolderWorkspaceHostId(row.folderWorkspace, row.projectGroup, defaultHostId)
@@ -87,10 +87,17 @@ describe('imported worktrees card actions', () => {
})
it('dismisses the card when keep-hidden update succeeds', async () => {
await keepImportedWorktreesHiddenCard({ projectId, updateRepo, setCardState })
await keepImportedWorktreesHiddenCard({
projectId,
updateRepo,
setCardState,
hiddenWorktreePaths: ['/scratch/external'],
existingBaselinePaths: ['/scratch/old']
})
expect(updateRepo).toHaveBeenCalledWith(projectId, {
externalWorktreeVisibilityPromptDismissedAt: expect.any(Number)
externalWorktreeVisibilityPromptDismissedAt: expect.any(Number),
externalWorktreeInboxBaselinePaths: ['/scratch/old', '/scratch/external']
})
expect(setCardState).toHaveBeenLastCalledWith(projectId, null)
})
@@ -1,4 +1,5 @@
import type { Repo } from '../../../../shared/types'
import { mergeExternalWorktreeInboxPaths } from '../../../../shared/external-worktree-inbox'
export type ImportedWorktreeCardActionState = {
pending: boolean
@@ -13,9 +14,16 @@ type ImportedWorktreeCardActionDeps = {
updateRepo: (
projectId: string,
updates: Partial<
Pick<Repo, 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt'>
Pick<
Repo,
| 'externalWorktreeVisibility'
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'externalWorktreeInboxBaselinePaths'
>
>
) => Promise<boolean>
hiddenWorktreePaths?: readonly string[]
existingBaselinePaths?: readonly string[]
fetchWorktrees: (
projectId: string,
options?: { requireAuthoritative?: boolean }
@@ -63,7 +71,15 @@ export async function keepImportedWorktreesHiddenCard(
): Promise<void> {
args.setCardState(args.projectId, { pending: true, error: null })
const updated = await args.updateRepo(args.projectId, {
externalWorktreeVisibilityPromptDismissedAt: Date.now()
externalWorktreeVisibilityPromptDismissedAt: Date.now(),
...(args.hiddenWorktreePaths && args.hiddenWorktreePaths.length > 0
? {
externalWorktreeInboxBaselinePaths: mergeExternalWorktreeInboxPaths(
args.existingBaselinePaths,
args.hiddenWorktreePaths
)
}
: {})
})
if (!updated) {
args.setCardState(args.projectId, {
@@ -1,9 +1,5 @@
import type {
DetectedWorktree,
DetectedWorktreeListResult,
Repo,
Worktree
} from '../../../../shared/types'
import type { DetectedWorktreeListResult, Repo, Worktree } from '../../../../shared/types'
import { getHiddenExternalWorktrees } from '../../../../shared/external-worktree-inbox'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import {
effectiveExternalWorktreeVisibility,
@@ -13,14 +9,8 @@ import type { ImportedWorktreesCardCandidate } from './worktree-list-groups'
export function getHiddenImportedWorktrees(
detected: DetectedWorktreeListResult | undefined
): DetectedWorktree[] {
if (detected?.authoritative !== true) {
return []
}
return detected.worktrees.filter(
(worktree) =>
!worktree.visible && !worktree.selectedCheckout && worktree.ownership !== 'orca-managed'
)
): ReturnType<typeof getHiddenExternalWorktrees> {
return getHiddenExternalWorktrees(detected)
}
export function buildImportedWorktreesCardCandidates(args: {
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/types'
import {
importNewExternalWorktreeInboxPaths,
keepNewExternalWorktreeInboxHidden,
suppressNewExternalWorktreeInbox
} from './new-external-worktrees-inbox-actions'
const projectId = 'repo-1'
const repo: Pick<Repo, 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths'> = {
externalWorktreeInboxBaselinePaths: ['/scratch/old'],
importedExternalWorktreePaths: []
}
describe('new external worktree inbox actions', () => {
it('imports inbox worktrees into the sidebar allowlist', async () => {
const updateRepo = vi.fn().mockResolvedValue(true)
const fetchWorktrees = vi.fn().mockResolvedValue(true)
const setInboxState = vi.fn()
await importNewExternalWorktreeInboxPaths({
projectId,
repo,
worktreePaths: ['/scratch/new'],
updateRepo,
fetchWorktrees,
setInboxState
})
expect(updateRepo).toHaveBeenCalledWith(projectId, {
importedExternalWorktreePaths: ['/scratch/new'],
externalWorktreeInboxBaselinePaths: ['/scratch/old', '/scratch/new']
})
expect(fetchWorktrees).toHaveBeenCalledWith(projectId, { requireAuthoritative: true })
expect(setInboxState).toHaveBeenLastCalledWith(projectId, null)
})
it('rolls import path lists back with explicit empty arrays when refresh fails', async () => {
const updateRepo = vi.fn().mockResolvedValue(true)
const fetchWorktrees = vi.fn().mockResolvedValue(false)
const setInboxState = vi.fn()
await importNewExternalWorktreeInboxPaths({
projectId,
repo: {},
worktreePaths: ['/scratch/new'],
updateRepo,
fetchWorktrees,
setInboxState
})
expect(updateRepo).toHaveBeenNthCalledWith(1, projectId, {
importedExternalWorktreePaths: ['/scratch/new'],
externalWorktreeInboxBaselinePaths: ['/scratch/new']
})
expect(updateRepo).toHaveBeenNthCalledWith(2, projectId, {
importedExternalWorktreePaths: [],
externalWorktreeInboxBaselinePaths: []
})
})
it('extends the inbox baseline when keeping a batch hidden', async () => {
const updateRepo = vi.fn().mockResolvedValue(true)
const setInboxState = vi.fn()
await keepNewExternalWorktreeInboxHidden({
projectId,
repo,
worktreePaths: ['/scratch/new'],
updateRepo,
fetchWorktrees: vi.fn(),
setInboxState
})
expect(setInboxState).toHaveBeenNthCalledWith(1, projectId, { pending: true, error: null })
expect(updateRepo).toHaveBeenCalledWith(projectId, {
externalWorktreeInboxBaselinePaths: ['/scratch/old', '/scratch/new']
})
expect(setInboxState).toHaveBeenLastCalledWith(projectId, null)
})
it('permanently suppresses the inbox and baselines the current batch', async () => {
const updateRepo = vi.fn().mockResolvedValue(true)
const setInboxState = vi.fn()
const suppressed = await suppressNewExternalWorktreeInbox({
projectId,
repo,
worktreePaths: ['/scratch/new'],
updateRepo,
setInboxState
})
expect(suppressed).toBe(true)
expect(updateRepo).toHaveBeenCalledWith(projectId, {
externalWorktreeDiscoverySuppressedAt: expect.any(Number),
externalWorktreeInboxBaselinePaths: ['/scratch/old', '/scratch/new']
})
expect(setInboxState).toHaveBeenLastCalledWith(projectId, null)
})
})
@@ -0,0 +1,148 @@
import type { Repo } from '../../../../shared/types'
import { mergeExternalWorktreeInboxPaths } from '../../../../shared/external-worktree-inbox'
import { translate } from '@/i18n/i18n'
export type NewExternalWorktreesInboxActionState = {
pending: boolean
error: string | null
}
type RepoExternalWorktreeInboxUpdate = Partial<
Pick<
Repo,
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
| 'externalWorktreeDiscoverySuppressedAt'
>
>
type NewExternalWorktreesInboxActionDeps = {
projectId: string
repo: Pick<Repo, 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths'>
worktreePaths: readonly string[]
setInboxState: (projectId: string, state: NewExternalWorktreesInboxActionState | null) => void
updateRepo: (projectId: string, updates: RepoExternalWorktreeInboxUpdate) => Promise<boolean>
fetchWorktrees: (
projectId: string,
options?: { requireAuthoritative?: boolean }
) => Promise<boolean>
}
function newExternalWorktreeInboxKeepHiddenError(): string {
return translate(
'auto.components.sidebar.newExternalWorktreesInboxActions.a11c2f6d89',
'Could not keep external worktrees hidden. Try again.'
)
}
function newExternalWorktreeInboxImportError(): string {
return translate(
'auto.components.sidebar.newExternalWorktreesInboxActions.b7e4d1a062',
'Could not import external worktrees. Try again.'
)
}
function newExternalWorktreeInboxSuppressError(): string {
return translate(
'auto.components.sidebar.newExternalWorktreesInboxActions.c94f0b3a15',
'Could not hide external worktrees permanently. Try again.'
)
}
function rollbackPathList(paths: readonly string[] | undefined): string[] {
return [...(paths ?? [])]
}
async function refreshAfterRepoInboxUpdate(
args: NewExternalWorktreesInboxActionDeps,
updates: RepoExternalWorktreeInboxUpdate,
rollbackUpdates: RepoExternalWorktreeInboxUpdate
): Promise<boolean> {
args.setInboxState(args.projectId, { pending: true, error: null })
const updated = await args.updateRepo(args.projectId, updates)
if (!updated) {
args.setInboxState(args.projectId, {
pending: false,
error: newExternalWorktreeInboxImportError()
})
return false
}
const refreshed = await args.fetchWorktrees(args.projectId, { requireAuthoritative: true })
if (!refreshed) {
await args.updateRepo(args.projectId, rollbackUpdates)
args.setInboxState(args.projectId, {
pending: false,
error: newExternalWorktreeInboxImportError()
})
return false
}
args.setInboxState(args.projectId, null)
return true
}
export async function keepNewExternalWorktreeInboxHidden(
args: NewExternalWorktreesInboxActionDeps
): Promise<void> {
args.setInboxState(args.projectId, { pending: true, error: null })
const baseline = mergeExternalWorktreeInboxPaths(
args.repo.externalWorktreeInboxBaselinePaths,
args.worktreePaths
)
const updated = await args.updateRepo(args.projectId, {
externalWorktreeInboxBaselinePaths: baseline
})
if (!updated) {
args.setInboxState(args.projectId, {
pending: false,
error: newExternalWorktreeInboxKeepHiddenError()
})
return
}
args.setInboxState(args.projectId, null)
}
export async function importNewExternalWorktreeInboxPaths(
args: NewExternalWorktreesInboxActionDeps
): Promise<void> {
const importedExternalWorktreePaths = mergeExternalWorktreeInboxPaths(
args.repo.importedExternalWorktreePaths,
args.worktreePaths
)
const externalWorktreeInboxBaselinePaths = mergeExternalWorktreeInboxPaths(
args.repo.externalWorktreeInboxBaselinePaths,
args.worktreePaths
)
await refreshAfterRepoInboxUpdate(
args,
{ importedExternalWorktreePaths, externalWorktreeInboxBaselinePaths },
{
importedExternalWorktreePaths: rollbackPathList(args.repo.importedExternalWorktreePaths),
externalWorktreeInboxBaselinePaths: rollbackPathList(
args.repo.externalWorktreeInboxBaselinePaths
)
}
)
}
export async function suppressNewExternalWorktreeInbox(
args: Omit<NewExternalWorktreesInboxActionDeps, 'fetchWorktrees'>
): Promise<boolean> {
args.setInboxState(args.projectId, { pending: true, error: null })
const externalWorktreeInboxBaselinePaths = mergeExternalWorktreeInboxPaths(
args.repo.externalWorktreeInboxBaselinePaths,
args.worktreePaths
)
const updated = await args.updateRepo(args.projectId, {
externalWorktreeDiscoverySuppressedAt: Date.now(),
externalWorktreeInboxBaselinePaths
})
if (!updated) {
args.setInboxState(args.projectId, {
pending: false,
error: newExternalWorktreeInboxSuppressError()
})
return false
}
args.setInboxState(args.projectId, null)
return true
}
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import type {
DetectedWorktree,
DetectedWorktreeListResult,
Repo,
Worktree
} from '../../../../shared/types'
import { buildNewExternalWorktreesInboxCandidates } from './new-external-worktrees-inbox-candidates'
const repo: Repo = {
id: 'repo-1',
path: '/repo',
displayName: 'orca',
badgeColor: '#000000',
addedAt: Date.UTC(2026, 4, 24),
externalWorktreeVisibility: 'hide',
externalWorktreeVisibilityPromptDismissedAt: 1,
externalWorktreeInboxBaselinePaths: ['/scratch/old-one']
}
const visibleWorktree: Worktree = {
id: 'repo-1::/repo',
repoId: repo.id,
path: '/repo',
displayName: 'main',
branch: 'refs/heads/main',
head: 'abc123',
isBare: false,
isMainWorktree: true,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}
function detectedWorktree(overrides: Partial<DetectedWorktree> = {}): DetectedWorktree {
return {
...visibleWorktree,
id: 'repo-1::/scratch/new-one',
path: '/scratch/new-one',
displayName: 'new-one',
isMainWorktree: false,
ownership: 'external',
selectedCheckout: false,
visible: false,
...overrides
}
}
function detectedResult(worktrees: DetectedWorktree[]): DetectedWorktreeListResult {
return {
repoId: repo.id,
authoritative: true,
source: 'git',
worktrees
}
}
describe('buildNewExternalWorktreesInboxCandidates', () => {
it('builds inbox candidates only after the initial prompt is dismissed', () => {
const detectedWorktreesByRepo = {
[repo.id]: detectedResult([detectedWorktree()])
}
expect(
buildNewExternalWorktreesInboxCandidates({
repos: [repo],
visibleWorktrees: [visibleWorktree],
detectedWorktreesByRepo
}).get(repo.id)
).toMatchObject({
repo: { id: repo.id },
inboxWorktrees: [{ id: 'repo-1::/scratch/new-one' }]
})
expect(
buildNewExternalWorktreesInboxCandidates({
repos: [{ ...repo, externalWorktreeVisibilityPromptDismissedAt: undefined }],
visibleWorktrees: [visibleWorktree],
detectedWorktreesByRepo
}).size
).toBe(0)
})
it('suppresses inbox candidates when discovery is permanently hidden or visibility is show', () => {
const detectedWorktreesByRepo = { [repo.id]: detectedResult([detectedWorktree()]) }
expect(
buildNewExternalWorktreesInboxCandidates({
repos: [{ ...repo, externalWorktreeDiscoverySuppressedAt: 1 }],
detectedWorktreesByRepo
}).size
).toBe(0)
expect(
buildNewExternalWorktreesInboxCandidates({
repos: [{ ...repo, externalWorktreeVisibility: 'show' }],
detectedWorktreesByRepo
}).size
).toBe(0)
})
})
@@ -0,0 +1,57 @@
import type {
DetectedWorktree,
DetectedWorktreeListResult,
Repo,
Worktree
} from '../../../../shared/types'
import { getNewExternalWorktreeInboxWorktrees } from '../../../../shared/external-worktree-inbox'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { NewExternalWorktreesInboxCandidate } from './worktree-list-groups'
export function buildNewExternalWorktreesInboxCandidates(args: {
repos: readonly Repo[]
visibleWorktrees?: readonly Worktree[]
detectedWorktreesByRepo: Readonly<Record<string, DetectedWorktreeListResult | undefined>>
filterRepoIds?: readonly string[]
}): Map<string, NewExternalWorktreesInboxCandidate> {
const visibleRepoIds = args.visibleWorktrees
? new Set(args.visibleWorktrees.map((worktree) => worktree.repoId))
: null
const filterRepoIds = args.filterRepoIds?.length ? new Set(args.filterRepoIds) : null
const candidates = new Map<string, NewExternalWorktreesInboxCandidate>()
for (const repo of args.repos) {
if (filterRepoIds && !filterRepoIds.has(repo.id)) {
continue
}
if (visibleRepoIds && !visibleRepoIds.has(repo.id)) {
continue
}
if (!isGitRepoKind(repo)) {
continue
}
const inboxWorktrees = getNewExternalWorktreeInboxWorktrees(
args.detectedWorktreesByRepo[repo.id],
repo
)
if (inboxWorktrees.length > 0) {
candidates.set(repo.id, { repo, inboxWorktrees })
}
}
return candidates
}
export type NewExternalWorktreeInboxPreview = Pick<
DetectedWorktree,
'id' | 'displayName' | 'path' | 'branch'
>
export function toNewExternalWorktreeInboxPreview(
worktree: DetectedWorktree
): NewExternalWorktreeInboxPreview {
return {
id: worktree.id,
displayName: worktree.displayName,
path: worktree.path,
branch: worktree.branch
}
}
@@ -10,6 +10,7 @@ type WorktreeDragUnitRow =
| { type: 'header'; key: string }
| { type: 'item'; worktree: { id: string }; depth: number; sectionKey: string }
| { type: 'imported-worktrees-card' }
| { type: 'new-external-worktrees-inbox' }
| { type: 'pending-creation' }
| { type: 'folder-workspace' }
@@ -32,6 +33,7 @@ export function getWorktreeDragUnitGroups(
if (
row.type === 'host-header' ||
row.type === 'imported-worktrees-card' ||
row.type === 'new-external-worktrees-inbox' ||
row.type === 'pending-creation' ||
row.type === 'folder-workspace'
) {
@@ -351,6 +351,7 @@ describe('buildRows with pinned worktrees', () => {
[],
new Set(),
new Map(),
new Map(),
[],
{ projects: [project], projectHostSetups }
)
@@ -441,6 +442,7 @@ describe('buildRows with pinned worktrees', () => {
[],
new Set(),
new Map(),
new Map(),
[],
{
projects: projection.projects,
@@ -461,6 +463,95 @@ describe('buildRows with pinned worktrees', () => {
])
})
it('keeps mixed-host project item order while inserting inbox rows before worktrees', () => {
const localRepo: Repo = {
...repo,
id: 'local-sample-app',
displayName: 'sample-app',
gitRemoteIdentity: {
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'origin',
remoteUrl: 'https://git.company.test/team/sample-app.git'
}
}
const sshRepo: Repo = {
...repo,
id: 'ssh-sample-app',
path: '/home/alice/src/sample-app',
displayName: 'sample-app',
connectionId: 'build server',
gitRemoteIdentity: {
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'origin',
remoteUrl: 'https://git.company.test/team/sample-app.git'
}
}
const localFirst: Worktree = {
...worktree,
id: 'wt-local-first',
repoId: localRepo.id,
path: '/Users/alice/work/sample-app-a'
}
const sshWorktree: Worktree = {
...worktree,
id: 'wt-ssh',
repoId: sshRepo.id,
path: '/home/alice/src/sample-app-b'
}
const localSecond: Worktree = {
...worktree,
id: 'wt-local-second',
repoId: localRepo.id,
path: '/Users/alice/work/sample-app-c'
}
const projection = projectHostSetupProjectionFromRepos([localRepo, sshRepo])
const rows = buildRows(
'repo',
[localFirst, sshWorktree, localSecond],
new Map([
[localRepo.id, localRepo],
[sshRepo.id, sshRepo]
]),
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map([
[localFirst.id, localFirst],
[sshWorktree.id, sshWorktree],
[localSecond.id, localSecond]
]),
false,
undefined,
[],
new Set(),
new Map(),
new Map([
[
localRepo.id,
{ repo: localRepo, inboxWorktrees: [makeDetectedWorktree({ id: 'local-inbox' })] }
],
[sshRepo.id, { repo: sshRepo, inboxWorktrees: [makeDetectedWorktree({ id: 'ssh-inbox' })] }]
]),
[],
{
projects: projection.projects,
projectHostSetups: projection.setups
}
)
expect(rows).toMatchObject([
{ type: 'header' },
{ type: 'new-external-worktrees-inbox', repo: { id: localRepo.id } },
{ type: 'new-external-worktrees-inbox', repo: { id: sshRepo.id } },
{ type: 'item', worktree: { id: localFirst.id } },
{ type: 'item', worktree: { id: sshWorktree.id } },
{ type: 'item', worktree: { id: localSecond.id } }
])
})
it('orders project identity headers by the manual repo order anchor', () => {
const analyticsProject: Project = {
...project,
@@ -519,6 +610,7 @@ describe('buildRows with pinned worktrees', () => {
[],
new Set(),
new Map(),
new Map(),
[],
{
projects: [project, analyticsProject],
@@ -575,6 +667,7 @@ describe('buildRows with pinned worktrees', () => {
[],
new Set(),
new Map(),
new Map(),
[],
{
projects: [{ ...project, sourceRepoIds: [repo.id, repoB.id] }],
@@ -643,6 +736,7 @@ describe('buildRows with pinned worktrees', () => {
[],
new Set(),
new Map(),
new Map(),
[],
{
projects: [{ ...project, sourceRepoIds: [repo.id, localRepoB.id, remoteRepo.id] }],
@@ -722,6 +816,7 @@ describe('buildRows with pinned worktrees', () => {
[],
new Set(),
new Map(),
new Map(),
[],
{
projects: [{ ...project, sourceRepoIds: [windowsRepo.id, wslRepo.id] }],
@@ -780,6 +875,7 @@ describe('buildRows with pinned worktrees', () => {
[],
new Set(),
new Map(),
new Map(),
[],
{ projects: [project], projectHostSetups: [projectHostSetups[0]!, runtimeSetup] },
[],
@@ -821,6 +917,7 @@ describe('buildRows with pinned worktrees', () => {
[],
new Set(),
new Map(),
new Map(),
[],
{
projects: [{ ...project, sourceRepoIds: [repo.id] }],
@@ -1035,6 +1132,117 @@ describe('buildRows with pinned worktrees', () => {
expect(rows.some((row) => row.type === 'imported-worktrees-card')).toBe(false)
})
it('emits a new external worktrees inbox row before repo worktree rows', () => {
const inboxWorktrees = [
makeDetectedWorktree({ id: 'inbox-1', displayName: 'payments-refactor' }),
makeDetectedWorktree({ id: 'inbox-2', displayName: 'auth-cache-debug' })
]
const rows = buildRows(
'repo',
[worktree],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map([[worktree.id, worktree]]),
false,
undefined,
[],
new Set(),
new Map(),
new Map([[repo.id, { repo, inboxWorktrees }]])
)
expect(rows).toMatchObject([
{ type: 'header', key: 'repo:repo-1' },
{
type: 'new-external-worktrees-inbox',
key: 'new-external-worktrees-inbox:repo-1',
repo: { id: 'repo-1' },
inboxWorktrees: [{ id: 'inbox-1' }, { id: 'inbox-2' }]
},
{ type: 'item', worktree: { id: 'wt-1' } }
])
})
it('suppresses the new external worktrees inbox row when the repo group is collapsed', () => {
const rows = buildRows(
'repo',
[worktree],
repoMap,
null,
new Set(['repo:repo-1']),
undefined,
undefined,
undefined,
{},
new Map([[worktree.id, worktree]]),
false,
undefined,
[],
new Set(),
new Map(),
new Map([[repo.id, { repo, inboxWorktrees: [makeDetectedWorktree()] }]])
)
expect(rows).toMatchObject([{ type: 'header', key: 'repo:repo-1' }])
})
it('emits a repo header and inbox row when no visible worktree rows remain', () => {
const rows = buildRows(
'repo',
[],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map(),
false,
undefined,
[],
new Set(),
new Map(),
new Map([[repo.id, { repo, inboxWorktrees: [makeDetectedWorktree()] }]])
)
expect(rows).toMatchObject([
{ type: 'header', key: 'repo:repo-1' },
{
type: 'new-external-worktrees-inbox',
key: 'new-external-worktrees-inbox:repo-1'
}
])
})
it('does not emit new external worktrees inbox rows outside repo grouping', () => {
const rows = buildRows(
'workspace-status',
[worktree],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map([[worktree.id, worktree]]),
false,
undefined,
[],
new Set(),
new Map(),
new Map([[repo.id, { repo, inboxWorktrees: [makeDetectedWorktree()] }]])
)
expect(rows.some((row) => row.type === 'new-external-worktrees-inbox')).toBe(false)
})
it('emits imported worktree cards in repo groups when visible rows are pinned', () => {
const repoTwo: Repo = { ...repo, id: 'repo-2', displayName: 'auth-service' }
const pinnedOneA = { ...worktree, id: 'repo-1-pinned-a', isPinned: true }
@@ -2186,6 +2394,7 @@ describe('project groups', () => {
[group],
new Set(),
new Map(),
new Map(),
[],
undefined,
[folderWorkspace]
@@ -2263,6 +2472,7 @@ describe('project groups', () => {
[rootGroup, childGroup],
new Set(),
new Map(),
new Map(),
[],
undefined,
[folderWorkspace]
@@ -2332,6 +2542,7 @@ describe('project groups', () => {
[group],
new Set(),
new Map(),
new Map(),
[],
undefined,
[folderWorkspace]
@@ -2774,6 +2985,7 @@ describe('buildRows pending creations', () => {
[],
new Set(),
new Map(),
new Map(),
[makePendingCreation('c1', repo.id)]
)
@@ -2805,6 +3017,7 @@ describe('buildRows pending creations', () => {
[],
new Set(),
new Map(),
new Map(),
[makePendingCreation('c1', repo.id)]
)
@@ -2828,6 +3041,7 @@ describe('buildRows pending creations', () => {
[],
new Set(),
new Map(),
new Map(),
[makePendingCreation('c1', repo.id)]
)
@@ -2854,6 +3068,7 @@ describe('buildRows pending creations', () => {
[],
new Set(),
new Map(),
new Map(),
[makePendingCreation('c1', repo.id)]
)
@@ -82,6 +82,18 @@ export type ImportedWorktreesCardRow = {
placement: 'repo-group' | 'pinned-fallback'
}
export type NewExternalWorktreesInboxCandidate = {
repo: Repo
inboxWorktrees: DetectedWorktree[]
}
export type NewExternalWorktreesInboxRow = {
type: 'new-external-worktrees-inbox'
key: string
repo: Repo
inboxWorktrees: DetectedWorktree[]
}
export type PendingCreationRow = {
type: 'pending-creation'
key: string
@@ -108,6 +120,7 @@ export type Row =
| GroupHeaderRow
| WorktreeRow
| ImportedWorktreesCardRow
| NewExternalWorktreesInboxRow
| PendingCreationRow
| FolderWorkspaceRow
@@ -471,6 +484,17 @@ function buildImportedWorktreesCardRow(
}
}
function buildNewExternalWorktreesInboxRow(
candidate: NewExternalWorktreesInboxCandidate
): NewExternalWorktreesInboxRow {
return {
type: 'new-external-worktrees-inbox',
key: `new-external-worktrees-inbox:${candidate.repo.id}`,
repo: candidate.repo,
inboxWorktrees: candidate.inboxWorktrees
}
}
function buildWorktreeRow(
worktree: Worktree,
repoMap: Map<string, Repo>,
@@ -801,6 +825,10 @@ export function buildRows(
projectGroups: readonly ProjectGroup[] = [],
placeholderRepoIds: ReadonlySet<string> = new Set(),
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate> = new Map(),
newExternalWorktreesInboxByRepo: ReadonlyMap<
string,
NewExternalWorktreesInboxCandidate
> = new Map(),
pendingCreations: readonly PendingCreationRef[] = [],
projectGrouping?: ProjectGroupingModel,
folderWorkspaces: readonly FolderWorkspace[] = [],
@@ -927,6 +955,22 @@ export function buildRows(
}
}
}
if (groupBy === 'repo') {
for (const [repoId, candidate] of newExternalWorktreesInboxByRepo) {
const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex)
const key = grouping.key
if (!grouped.has(key) && !visiblePinnedRepoIds.has(repoId)) {
grouped.set(key, {
label: grouping.label,
items: [],
repo: grouping.repo ?? candidate.repo,
repoIds: new Set([repoId])
})
} else if (grouped.has(key)) {
addRepoIdToGroup(grouped.get(key)!, repoId)
}
}
}
if (groupBy === 'repo') {
for (const repoId of pendingByRepo.keys()) {
const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex)
@@ -1050,6 +1094,12 @@ export function buildRows(
result.push(buildImportedWorktreesCardRow(candidate, 'repo-group'))
}
}
for (const repoId of repoIds) {
const candidate = newExternalWorktreesInboxByRepo.get(repoId)
if (candidate) {
result.push(buildNewExternalWorktreesInboxRow(candidate))
}
}
// Why: surface in-progress creates at the top of their own repo so the
// new workspace appears where it will land, not flashed to the very top
// of the sidebar.
@@ -1064,13 +1114,23 @@ export function buildRows(
groupBy === 'repo'
? getMixedHostContextLabels(group, repoMap, projectIndex, hostLabelById)
: undefined
appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, {
nestLineage,
collapsedGroups,
groupDepth: projectGroupDepth,
sectionKey: key,
hostContextLabelByRepoId
})
if (groupBy === 'repo') {
appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, {
nestLineage,
collapsedGroups,
groupDepth: projectGroupDepth,
sectionKey: key,
hostContextLabelByRepoId
})
} else {
appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, {
nestLineage,
collapsedGroups,
groupDepth: projectGroupDepth,
sectionKey: key,
hostContextLabelByRepoId
})
}
}
}
}
@@ -60,7 +60,7 @@ export function estimateRenderRowSize(
if (row?.type === 'lineage-group') {
return 100 + Math.max(0, row.rows.length - 1) * 96
}
if (row?.type === 'imported-worktrees-card') {
if (row?.type === 'imported-worktrees-card' || row?.type === 'new-external-worktrees-inbox') {
return IMPORTED_WORKTREES_LINE_ROW_HEIGHT
}
if (row?.type === 'pending-creation') {
@@ -11,6 +11,7 @@ import type * as UseNotificationDispatchModule from './use-notification-dispatch
import { getEagerPtyBufferHandle } from './pty-dispatcher'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import type { TerminalLayoutSnapshot } from '../../../../shared/types'
import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../../../shared/setup-agent-sequencing'
// Repro command:
// pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts -t "OpenTUI-style small ANSI redraw"
@@ -2879,6 +2880,64 @@ describe('connectPanePty', () => {
}
})
it('uses the sequenced startup command hint for SSH shell-ready detection', async () => {
const pendingTimeouts: (() => void)[] = []
const originalSetTimeout = globalThis.setTimeout
globalThis.setTimeout = vi.fn((fn: () => void) => {
pendingTimeouts.push(fn)
return 999 as unknown as ReturnType<typeof setTimeout>
}) as unknown as typeof setTimeout
try {
const { connectPanePty } = await import('./pty-connection')
const capturedDataCallback: { current: ((data: string) => void) | null } = {
current: null
}
const transport = createMockTransport('pty-id')
transport.connect.mockImplementation(
async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-ssh-1'
}
)
transportFactoryQueue.push(transport)
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }]
}
const wrapperCommand = 'bash -lc wait-for-setup-wrapper'
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
startup: {
command: wrapperCommand,
env: {
[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex --prefill 'linked issue context'"
}
}
})
connectPanePty(pane as never, manager as never, deps as never)
capturedDataCallback.current?.('user@remote $ ')
for (const fn of pendingTimeouts.splice(0)) {
fn()
}
expect(transport.sendInput).not.toHaveBeenCalled()
capturedDataCallback.current?.('\x1b]777;orca-shell-ready\x07user@remote $ ')
for (const fn of pendingTimeouts.splice(0)) {
fn()
}
expect(transport.sendInput).toHaveBeenCalledWith(`${wrapperCommand}\r`)
} finally {
globalThis.setTimeout = originalSetTimeout
}
})
it('drops agent status without retaining when OSC 133 reports the command finished', async () => {
const { connectPanePty } = await import('./pty-connection')
@@ -6215,6 +6274,249 @@ describe('connectPanePty', () => {
disposable.dispose()
})
it('abandons a stalled hidden restore and drains pending foreground chunks warning-first', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: {
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
} = { current: null }
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-id'
})
transportFactoryQueue.push(transport)
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
typeof vi.fn
>
const snapshot = createDeferred<{ data: string; cols: number; rows: number; seq: number }>()
getMainBufferSnapshot.mockReturnValue(snapshot.promise)
const hidden = 'hidden-codex-output\r\n'
const firstLive = 'first-live-output\r\n'
const secondLive = 'second-live-output\r\n'
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
isVisibleRef: { current: false },
startup: { command: 'codex' }
})
const disposable = connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
expect(capturedDataCallback.current).not.toBeNull()
vi.useFakeTimers()
capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length })
;(deps.isVisibleRef as { current: boolean }).current = true
capturedDataCallback.current?.(firstLive, {
seq: hidden.length + firstLive.length,
rawLength: firstLive.length
})
await flushAsyncTicks(4)
capturedDataCallback.current?.(secondLive, {
seq: hidden.length + firstLive.length + secondLive.length,
rawLength: secondLive.length
})
expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 })
expect(pane.terminal.write).not.toHaveBeenCalledWith(firstLive, expect.any(Function))
expect(pane.terminal.write).not.toHaveBeenCalledWith(secondLive, expect.any(Function))
vi.advanceTimersByTime(749)
await flushAsyncTicks(4)
expect(pane.terminal.write).not.toHaveBeenCalledWith(
expect.stringContaining('main recovery was unavailable'),
expect.any(Function)
)
vi.advanceTimersByTime(1)
vi.advanceTimersByTime(0)
await flushAsyncTicks(10)
const written = pane.terminal.write.mock.calls.map(([data]) => data as string)
const warningIndex = written.findIndex((data) => data.includes('main recovery was unavailable'))
const combinedLiveIndex = written.indexOf(firstLive + secondLive)
expect(warningIndex).toBeGreaterThanOrEqual(0)
expect(combinedLiveIndex).toBeGreaterThan(warningIndex)
snapshot.resolve({
data: 'late-snapshot-state\r\n',
cols: 100,
rows: 30,
seq: hidden.length + firstLive.length + secondLive.length
})
await flushAsyncTicks(20)
expect(pane.terminal.write).not.toHaveBeenCalledWith(
'late-snapshot-state\r\n',
expect.any(Function)
)
disposable.dispose()
})
it('falls back after repeated null hidden restore retries and drains blocked foreground', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: {
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
} = { current: null }
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-id'
})
transportFactoryQueue.push(transport)
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
typeof vi.fn
>
getMainBufferSnapshot.mockResolvedValue(null)
const hidden = 'hidden-codex-output\r\n'
const live = 'visible-after-null-retries\r\n'
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
isVisibleRef: { current: false },
startup: { command: 'codex' }
})
const disposable = connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
expect(capturedDataCallback.current).not.toBeNull()
vi.useFakeTimers()
capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length })
;(deps.isVisibleRef as { current: boolean }).current = true
capturedDataCallback.current?.(live, {
seq: hidden.length + live.length,
rawLength: live.length
})
await flushAsyncTicks(10)
for (let attempt = 0; attempt < 3; attempt++) {
expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function))
vi.advanceTimersByTime(50)
vi.advanceTimersByTime(0)
await flushAsyncTicks(10)
}
const written = pane.terminal.write.mock.calls.map(([data]) => data as string)
const warningIndex = written.findIndex((data) => data.includes('main recovery was unavailable'))
const liveIndex = written.indexOf(live)
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(4)
expect(warningIndex).toBeGreaterThanOrEqual(0)
expect(liveIndex).toBeGreaterThan(warningIndex)
disposable.dispose()
})
it('drops pending foreground overflow when a stalled hidden restore falls back', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: {
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
} = { current: null }
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-id'
})
transportFactoryQueue.push(transport)
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
typeof vi.fn
>
getMainBufferSnapshot.mockReturnValue(
createDeferred<{ data: string; cols: number; rows: number; seq: number }>().promise
)
const hidden = 'hidden-codex-output\r\n'
const liveOverflow = 'v'.repeat(512 * 1024 + 1)
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
isVisibleRef: { current: false },
startup: { command: 'codex' }
})
const disposable = connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
expect(capturedDataCallback.current).not.toBeNull()
vi.useFakeTimers()
capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length })
;(deps.isVisibleRef as { current: boolean }).current = true
capturedDataCallback.current?.(liveOverflow, {
seq: hidden.length + liveOverflow.length,
rawLength: liveOverflow.length
})
await flushAsyncTicks(4)
vi.advanceTimersByTime(750)
await vi.runAllTimersAsync()
await flushAsyncTicks(20)
vi.advanceTimersByTime(0)
await flushAsyncTicks(10)
expect(pane.terminal.write).toHaveBeenCalledWith(
expect.stringContaining('main recovery was unavailable'),
expect.any(Function)
)
expect(pane.terminal.write).not.toHaveBeenCalledWith(liveOverflow, expect.any(Function))
disposable.dispose()
})
it('coalesces tiny pending foreground chunks when stalled hidden restore falls back', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: {
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
} = { current: null }
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-id'
})
transportFactoryQueue.push(transport)
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
typeof vi.fn
>
getMainBufferSnapshot.mockReturnValue(
createDeferred<{ data: string; cols: number; rows: number; seq: number }>().promise
)
const hidden = 'hidden-codex-output\r\n'
const chunkCount = 2_000
const liveChunk = 'x'
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
isVisibleRef: { current: false },
startup: { command: 'codex' }
})
const disposable = connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
expect(capturedDataCallback.current).not.toBeNull()
vi.useFakeTimers()
capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length })
;(deps.isVisibleRef as { current: boolean }).current = true
for (let index = 0; index < chunkCount; index += 1) {
capturedDataCallback.current?.(liveChunk, {
seq: hidden.length + index + 1,
rawLength: liveChunk.length
})
}
await flushAsyncTicks(4)
vi.advanceTimersByTime(750)
vi.advanceTimersByTime(0)
await flushAsyncTicks(10)
const written = pane.terminal.write.mock.calls.map(([data]) => data as string)
const warningIndex = written.findIndex((data) => data.includes('main recovery was unavailable'))
const combinedLive = liveChunk.repeat(chunkCount)
const liveWrites = written.filter((data) => data === combinedLive)
expect(warningIndex).toBeGreaterThanOrEqual(0)
expect(liveWrites).toHaveLength(1)
expect(written.indexOf(combinedLive)).toBeGreaterThan(warningIndex)
disposable.dispose()
expect(vi.getTimerCount()).toBe(0)
})
it('keeps foreground output when hidden-backlog snapshot recovery is unavailable', async () => {
const pendingTimeouts: {
canceled: boolean
@@ -6324,6 +6626,106 @@ describe('connectPanePty', () => {
}
})
it('keeps a newer same-PTY hidden restore after a timed-out snapshot resolves late', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: {
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
} = { current: null }
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-id'
})
transportFactoryQueue.push(transport)
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
typeof vi.fn
>
const firstSnapshot = createDeferred<{
data: string
cols: number
rows: number
seq: number
}>()
const secondSnapshot = createDeferred<{
data: string
cols: number
rows: number
seq: number
}>()
getMainBufferSnapshot
.mockReturnValueOnce(firstSnapshot.promise)
.mockReturnValueOnce(secondSnapshot.promise)
const firstHidden = 'first-hidden-output\r\n'
const firstLive = 'first-live-output\r\n'
const secondHidden = 'second-hidden-output\r\n'
const secondLive = 'second-live-output\r\n'
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
isVisibleRef: { current: false },
startup: { command: 'codex' }
})
const disposable = connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
expect(capturedDataCallback.current).not.toBeNull()
vi.useFakeTimers()
capturedDataCallback.current?.(firstHidden, {
seq: firstHidden.length,
rawLength: firstHidden.length
})
;(deps.isVisibleRef as { current: boolean }).current = true
capturedDataCallback.current?.(firstLive, {
seq: firstHidden.length + firstLive.length,
rawLength: firstLive.length
})
await flushAsyncTicks(4)
vi.advanceTimersByTime(750)
vi.advanceTimersByTime(0)
await flushAsyncTicks(10)
pane.terminal.write.mockClear()
;(deps.isVisibleRef as { current: boolean }).current = false
capturedDataCallback.current?.(secondHidden, {
seq: firstHidden.length + firstLive.length + secondHidden.length,
rawLength: secondHidden.length
})
;(deps.isVisibleRef as { current: boolean }).current = true
capturedDataCallback.current?.(secondLive, {
seq: firstHidden.length + firstLive.length + secondHidden.length + secondLive.length,
rawLength: secondLive.length
})
await flushAsyncTicks(4)
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2)
firstSnapshot.resolve({
data: 'stale-first-snapshot\r\n',
cols: 100,
rows: 30,
seq: firstHidden.length + firstLive.length
})
await flushAsyncTicks(10)
secondSnapshot.resolve({
data: 'fresh-second-snapshot\r\n',
cols: 100,
rows: 30,
seq: firstHidden.length + firstLive.length + secondHidden.length + secondLive.length
})
await flushAsyncTicks(20)
expect(pane.terminal.write).not.toHaveBeenCalledWith(
'stale-first-snapshot\r\n',
expect.any(Function)
)
expect(pane.terminal.write).toHaveBeenCalledWith(
'fresh-second-snapshot\r\n',
expect.any(Function)
)
expect(pane.terminal.write).not.toHaveBeenCalledWith(secondLive, expect.any(Function))
disposable.dispose()
})
it('ignores an async hidden-backlog snapshot if the pane changes PTYs first', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('old-pty-id')
@@ -46,6 +46,7 @@ import {
} from './layout-serialization'
import { createShellReadyMarkerScanState, scanForShellReadyMarker } from './shell-ready-marker-scan'
import { shouldUseShellReadyStartupDelivery } from '../../../../shared/codex-startup-delivery'
import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing'
import { getSystemPrefersDark } from '@/lib/terminal-theme'
import {
mode2031SequenceFor,
@@ -141,6 +142,7 @@ const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000
const HIDDEN_OUTPUT_RESTORE_PENDING_CHARS = 512 * 1024
const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS = 50
const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX = 3
const HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS = 750
const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256
const CURSOR_SHOW_SEQUENCE = '\x1b[?25h'
const CURSOR_HIDE_SEQUENCE = '\x1b[?25l'
@@ -811,6 +813,7 @@ export function connectPanePty(
let unregisterBacklogRecovery: (() => void) | null = null
let unregisterDocumentVisibilityRecovery: (() => void) | null = null
let cleanupHiddenOutputRestoreDeferredRetry = (): void => {}
let cleanupHiddenOutputRestoreForegroundDeadline = (): void => {}
let unregisterE2ePtyDataInjection = (): void => {}
let startupInjectTimer: ReturnType<typeof setTimeout> | null = null
let sshShellReadyFallbackTimer: ReturnType<typeof setTimeout> | null = null
@@ -2107,6 +2110,37 @@ export function connectPanePty(
if (geometryReportObserver && pane.container instanceof Element) {
geometryReportObserver.observe(pane.container)
}
// Why: the deferred-rAF fit can spawn the PTY at a stale width when the pane's
// real (e.g. split/narrower) layout has not settled by the first frame — the
// PTY is born at the wide window width while xterm later reflows to the pane
// width. The corrective onResize is then dropped (cols already matched at fit
// time, or isRendererPtyResizeAuthoritative() was false mid-mount), pinning
// process.stdout.columns forever and garbling TUIs. Re-fit once layout has
// settled and force the PTY to xterm's dimensions; the initial spawn-time sync
// is authoritative by definition, so it bypasses the visibility gate (but not
// the mobile-fit override, which legitimately parks the PTY at phone dims).
const reconcilePtySizeAfterSpawn = (
ptyId: string,
spawnCols: number,
spawnRows: number
): void => {
requestAnimationFrame(() => {
if (disposed || transport.getPtyId() !== ptyId) {
return
}
if (getFitOverrideForPty(ptyId) || isPtyLocked(ptyId)) {
return
}
safeFit(pane)
const cols = pane.terminal.cols
const rows = pane.terminal.rows
if (cols > 0 && rows > 0 && (cols !== spawnCols || rows !== spawnRows)) {
transport.resize(cols, rows)
}
})
}
// Defer PTY spawn/attach to next frame so FitAddon has time to calculate
// the correct terminal dimensions from the laid-out container.
connectFrame = requestAnimationFrame(() => {
@@ -2196,10 +2230,14 @@ export function connectPanePty(
? { command: paneStartup.command }
: null
: null
const startupShellReadyCommandHint = resolveSetupAgentSequenceLaunchCommand(
paneStartup?.env ?? {},
paneStartup?.command
)
const shouldWaitForSshShellReady =
Boolean(connectionId) &&
shouldUseShellReadyStartupDelivery({
command: paneStartup?.command,
command: startupShellReadyCommandHint,
startupCommandDelivery: paneStartup?.startupCommandDelivery
}) &&
!shouldDeliverStartupViaTerminalPaste
@@ -2482,6 +2520,9 @@ export function connectPanePty(
// viable delivery target and must not wait for a future pane.
clearRegisteredStartupLaunchConfig()
}
if (resolvedPtyId) {
reconcilePtySizeAfterSpawn(resolvedPtyId, cols, rows)
}
const gen = await preSignalPromise
if (typeof gen === 'number' && resolvedPtyId) {
if (!isRemoteRuntimePtyId(resolvedPtyId)) {
@@ -2619,6 +2660,7 @@ export function connectPanePty(
let hiddenOutputRestoreRetryDeferred = false
let hiddenOutputRestoreScheduled = false
let hiddenOutputRestoreDeferredRetryTimer: ReturnType<typeof setTimeout> | null = null
let hiddenOutputRestoreForegroundDeadlineTimer: ReturnType<typeof setTimeout> | null = null
let hiddenOutputRestoreDeferredRetryAttempts = 0
// Why: hidden recovery state belongs to one PTY stream. Reattach/restart
// can reuse the pane object for a different session before visibility.
@@ -3031,6 +3073,7 @@ export function connectPanePty(
hiddenOutputRestorePendingChunks = []
hiddenOutputRestorePendingChars = 0
hiddenOutputRestorePendingOverflow = true
armHiddenOutputRestoreForegroundDeadline()
return
}
const pending: PendingHiddenOutputRestoreChunk = { data }
@@ -3042,6 +3085,7 @@ export function connectPanePty(
}
hiddenOutputRestorePendingChunks.push(pending)
hiddenOutputRestorePendingChars += data.length
armHiddenOutputRestoreForegroundDeadline()
}
function getChunkDataAfterSnapshot(
@@ -3110,34 +3154,10 @@ export function connectPanePty(
hiddenOutputRestoreScheduled = false
cancelScheduledHiddenOutputRestore(pane.terminal)
clearHiddenOutputRestoreDeferredRetryTimer()
clearHiddenOutputRestoreForegroundDeadlineTimer()
hiddenOutputRestoreDeferredRetryAttempts = 0
}
function drainPendingLiveChunksWithoutSnapshot(): void {
if (hiddenOutputRestorePendingOverflow) {
hiddenOutputRestorePendingChunks = []
hiddenOutputRestorePendingChars = 0
hiddenOutputRestorePendingOverflow = false
return
}
// Why: once snapshot retries are exhausted, these bounded chunks are the
// only known visible-era PTY bytes; replay them without overlap trimming.
while (hiddenOutputRestorePendingChunks.length > 0) {
const chunks = hiddenOutputRestorePendingChunks
hiddenOutputRestorePendingChunks = []
hiddenOutputRestorePendingChars = 0
for (const chunk of chunks) {
writePtyOutputToXterm(chunk.data, true)
}
if (hiddenOutputRestorePendingOverflow) {
hiddenOutputRestorePendingChunks = []
hiddenOutputRestorePendingChars = 0
hiddenOutputRestorePendingOverflow = false
return
}
}
}
function clearHiddenOutputRestoreDeferredRetryTimer(): void {
if (hiddenOutputRestoreDeferredRetryTimer === null) {
return
@@ -3147,6 +3167,81 @@ export function connectPanePty(
}
cleanupHiddenOutputRestoreDeferredRetry = clearHiddenOutputRestoreDeferredRetryTimer
function clearHiddenOutputRestoreForegroundDeadlineTimer(): void {
if (hiddenOutputRestoreForegroundDeadlineTimer === null) {
return
}
clearTimeout(hiddenOutputRestoreForegroundDeadlineTimer)
hiddenOutputRestoreForegroundDeadlineTimer = null
}
cleanupHiddenOutputRestoreForegroundDeadline = clearHiddenOutputRestoreForegroundDeadlineTimer
function armHiddenOutputRestoreForegroundDeadline(): void {
if (
disposed ||
hiddenOutputRestoreForegroundDeadlineTimer !== null ||
!shouldWritePtyOutputForeground(deps.isVisibleRef.current) ||
(hiddenOutputRestorePendingChunks.length === 0 && !hiddenOutputRestorePendingOverflow)
) {
return
}
const ptyId = hiddenOutputRestorePtyId
if (ptyId === null || transport.getPtyId() !== ptyId) {
return
}
const deadlineGeneration = hiddenOutputRestoreGeneration
// Why: only foreground-visible output blocked behind recovery gets a
// deadline; hidden-time restore work can continue without user impact.
hiddenOutputRestoreForegroundDeadlineTimer = setTimeout(() => {
hiddenOutputRestoreForegroundDeadlineTimer = null
if (
disposed ||
hiddenOutputRestoreGeneration !== deadlineGeneration ||
hiddenOutputRestorePtyId !== ptyId ||
!shouldWritePtyOutputForeground(deps.isVisibleRef.current)
) {
return
}
abandonHiddenOutputRestoreAndDrainPendingForeground(ptyId)
}, HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS)
}
function abandonHiddenOutputRestoreAndDrainPendingForeground(expectedPtyId: string): void {
if (transport.getPtyId() !== expectedPtyId || hiddenOutputRestorePtyId !== expectedPtyId) {
resetHiddenOutputRestoreIfPtyChanged()
return
}
const pendingChunks = hiddenOutputRestorePendingOverflow
? []
: hiddenOutputRestorePendingChunks.slice()
const hadPendingOverflow = hiddenOutputRestorePendingOverflow
hiddenOutputRestoreGeneration += 1
hiddenOutputRestoreInFlight = null
hiddenOutputRestoreNeeded = false
hiddenOutputRestorePtyId = null
hiddenOutputRestorePendingChunks = []
hiddenOutputRestorePendingChars = 0
hiddenOutputRestorePendingOverflow = false
hiddenOutputRestoreFreshSnapshotNeeded = false
hiddenOutputRestoreRetryDeferred = false
hiddenOutputRestoreScheduled = false
hiddenStartupRendererQueryPending = ''
hiddenRendererStateDirty = false
cancelScheduledHiddenOutputRestore(pane.terminal)
clearHiddenOutputRestoreDeferredRetryTimer()
clearHiddenOutputRestoreForegroundDeadlineTimer()
hiddenOutputRestoreDeferredRetryAttempts = 0
writeRestoreUnavailableWarning()
if (hadPendingOverflow) {
return
}
const pendingData = pendingChunks.map((chunk) => chunk.data).join('')
if (pendingData) {
writePtyOutputToXterm(pendingData, true)
}
}
function scheduleHiddenOutputRestoreDeferredRetry(): void {
if (
disposed ||
@@ -3156,9 +3251,13 @@ export function connectPanePty(
return
}
if (hiddenOutputRestoreDeferredRetryAttempts >= HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX) {
writeRestoreUnavailableWarning()
drainPendingLiveChunksWithoutSnapshot()
clearHiddenOutputRestoreState()
const ptyId = hiddenOutputRestorePtyId
if (ptyId !== null) {
abandonHiddenOutputRestoreAndDrainPendingForeground(ptyId)
} else {
clearHiddenOutputRestoreState()
writeRestoreUnavailableWarning()
}
return
}
hiddenOutputRestoreDeferredRetryAttempts += 1
@@ -3284,6 +3383,7 @@ export function connectPanePty(
}
hiddenOutputRestorePtyId = ptyId
if (hiddenOutputRestoreInFlight) {
armHiddenOutputRestoreForegroundDeadline()
return true
}
if (!opts?.bypassScheduler) {
@@ -3357,14 +3457,15 @@ export function connectPanePty(
if (disposed) {
return
}
if (
hiddenOutputRestoreGeneration !== restoreGeneration ||
transport.getPtyId() !== currentPtyId ||
hiddenOutputRestorePtyId !== currentPtyId
) {
const restoreGenerationChanged = hiddenOutputRestoreGeneration !== restoreGeneration
const restorePtyChanged =
transport.getPtyId() !== currentPtyId || hiddenOutputRestorePtyId !== currentPtyId
if (restoreGenerationChanged || restorePtyChanged) {
// Why: the snapshot belongs to the requested PTY; after reattach,
// replaying it would show stale/cleared output in the new terminal.
if (hiddenOutputRestorePtyId === currentPtyId) {
// A stale generation may be an abandoned timeout while a newer
// restore for the same PTY owns the current hidden-recovery state.
if (restorePtyChanged && hiddenOutputRestorePtyId === currentPtyId) {
clearHiddenOutputRestoreState()
}
return
@@ -3383,6 +3484,7 @@ export function connectPanePty(
if (drainPendingLiveChunksAfterSnapshot(snapshot.seq) && !needsFreshSnapshot) {
hiddenOutputRestoreNeeded = false
hiddenOutputRestorePtyId = null
clearHiddenOutputRestoreForegroundDeadlineTimer()
return
}
if (!shouldWritePtyOutputForeground(deps.isVisibleRef.current)) {
@@ -3394,10 +3496,16 @@ export function connectPanePty(
}
hiddenOutputRestoreNeeded = true
}
})().finally(() => {
hiddenOutputRestoreInFlight = null
})()
const hiddenOutputRestoreTask = hiddenOutputRestoreInFlight
let trackedHiddenOutputRestore: Promise<void>
trackedHiddenOutputRestore = hiddenOutputRestoreTask.finally(() => {
if (hiddenOutputRestoreInFlight === trackedHiddenOutputRestore) {
hiddenOutputRestoreInFlight = null
}
if (hiddenOutputRestorePendingChunks.length > 0 || hiddenOutputRestorePendingOverflow) {
hiddenOutputRestoreNeeded = true
armHiddenOutputRestoreForegroundDeadline()
}
if (
!hiddenOutputRestoreRetryDeferred &&
@@ -3407,6 +3515,7 @@ export function connectPanePty(
requestHiddenOutputRestoreIfNeeded()
}
})
hiddenOutputRestoreInFlight = trackedHiddenOutputRestore
return true
}
@@ -4301,6 +4410,7 @@ export function connectPanePty(
clearTerminalBellNotificationTimer()
clearReattachIdleAgentCursorResetTimer()
cleanupHiddenOutputRestoreDeferredRetry()
cleanupHiddenOutputRestoreForegroundDeadline()
unregisterBacklogRecovery?.()
unregisterBacklogRecovery = null
unregisterDocumentVisibilityRecovery?.()

Some files were not shown because too many files have changed in this diff Show More