From 2ed4346c2d59c61fc81fd2bea682df276a4b24df Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 21 Jun 2026 18:41:12 -0700 Subject: [PATCH 001/282] Coalesce renderer worktree change refreshes (#6020) Co-authored-by: Neil --- src/renderer/src/hooks/useIpcEvents.ts | 6 +- .../worktree-change-refresh-queue.test.ts | 142 ++++++++++++++++++ .../hooks/worktree-change-refresh-queue.ts | 76 ++++++++++ 3 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/hooks/worktree-change-refresh-queue.test.ts create mode 100644 src/renderer/src/hooks/worktree-change-refresh-queue.ts diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index a369db8d130..9111bb5be97 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -78,6 +78,7 @@ import { releaseBrowserAutomationVisibility } from '@/components/browser-pane/browser-automation-visibility' import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' +import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue' import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events' import { createRuntimeClientEventsSync } from './runtime-client-events-sync' import { detectLanguage } from '@/lib/language-detect' @@ -807,6 +808,7 @@ export function useIpcEvents(): void { afterState.removeWorkspaceSpaceWorktrees(removed) } } + const worktreeChangeRefreshQueue = createWorktreeChangeRefreshQueue(handleWorktreesChanged) const activateNotifiedWorktree = async ( { @@ -867,7 +869,7 @@ export function useIpcEvents(): void { } if (event.type === 'worktreesChanged') { void ensureRuntimeEventRepoKnown(environmentId, event.repoId).then(() => - handleWorktreesChanged(event.repoId) + worktreeChangeRefreshQueue.enqueue({ repoId: event.repoId }) ) return } @@ -935,7 +937,7 @@ export function useIpcEvents(): void { } // A folder rename changes the worktree id; handleWorktreesChanged // re-keys state and shields it from the deletion diff (see there). - await handleWorktreesChanged(data.repoId, data.renamed) + worktreeChangeRefreshQueue.enqueue(data) } ) ) diff --git a/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts b/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts new file mode 100644 index 00000000000..82ce23a3ecd --- /dev/null +++ b/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from 'vitest' +import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue' + +function deferred(): { + promise: Promise + resolve: (value: T | PromiseLike) => void +} { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +async function flushPromises(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +describe('createWorktreeChangeRefreshQueue', () => { + it('coalesces same-repo change bursts behind the active refresh', async () => { + const firstRefresh = deferred() + const handler = vi.fn(() => firstRefresh.promise) + const queue = createWorktreeChangeRefreshQueue(handler) + + queue.enqueue({ repoId: 'repo-1' }) + queue.enqueue({ repoId: 'repo-1' }) + queue.enqueue({ repoId: 'repo-1' }) + + expect(handler).toHaveBeenCalledTimes(1) + expect(handler).toHaveBeenCalledWith('repo-1', undefined) + + firstRefresh.resolve() + await flushPromises() + + expect(handler).toHaveBeenCalledTimes(2) + expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', undefined) + }) + + it('does not overlap refreshes for the same repo', async () => { + const firstRefresh = deferred() + const secondRefresh = deferred() + const handler = vi + .fn() + .mockReturnValueOnce(firstRefresh.promise) + .mockReturnValueOnce(secondRefresh.promise) + const queue = createWorktreeChangeRefreshQueue(handler) + + queue.enqueue({ repoId: 'repo-1' }) + queue.enqueue({ repoId: 'repo-1' }) + + expect(handler).toHaveBeenCalledTimes(1) + + firstRefresh.resolve() + await flushPromises() + + expect(handler).toHaveBeenCalledTimes(2) + + secondRefresh.resolve() + await flushPromises() + + expect(handler).toHaveBeenCalledTimes(2) + }) + + it('runs different repos independently', async () => { + const repoOneRefresh = deferred() + const handler = vi.fn((repoId: string) => + repoId === 'repo-1' ? repoOneRefresh.promise : Promise.resolve() + ) + const queue = createWorktreeChangeRefreshQueue(handler) + + queue.enqueue({ repoId: 'repo-1' }) + queue.enqueue({ repoId: 'repo-2' }) + + expect(handler).toHaveBeenCalledTimes(2) + expect(handler).toHaveBeenNthCalledWith(1, 'repo-1', undefined) + expect(handler).toHaveBeenNthCalledWith(2, 'repo-2', undefined) + + repoOneRefresh.resolve() + await flushPromises() + }) + + it('continues draining queued refreshes after a failed refresh', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const firstRefresh = deferred() + const handler = vi + .fn() + .mockReturnValueOnce(firstRefresh.promise) + .mockRejectedValueOnce(new Error('refresh failed')) + .mockResolvedValue(undefined) + const queue = createWorktreeChangeRefreshQueue(handler) + const renamed = { oldWorktreeId: 'wt-old', newWorktreeId: 'wt-new' } + + queue.enqueue({ repoId: 'repo-1' }) + queue.enqueue({ repoId: 'repo-1', renamed }) + queue.enqueue({ repoId: 'repo-1' }) + + firstRefresh.resolve() + await flushPromises() + + expect(handler).toHaveBeenCalledTimes(3) + expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', renamed) + expect(handler).toHaveBeenNthCalledWith(3, 'repo-1', undefined) + } finally { + consoleError.mockRestore() + } + }) + + it('preserves rename events instead of folding them into plain refreshes', async () => { + const handler = vi.fn(() => Promise.resolve()) + const queue = createWorktreeChangeRefreshQueue(handler) + const renamed = { oldWorktreeId: 'wt-old', newWorktreeId: 'wt-new' } + + queue.enqueue({ repoId: 'repo-1' }) + queue.enqueue({ repoId: 'repo-1', renamed }) + await flushPromises() + + expect(handler).toHaveBeenNthCalledWith(1, 'repo-1', undefined) + expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', renamed) + }) + + it('keeps a plain refresh queued after a rename', async () => { + const firstRefresh = deferred() + const handler = vi.fn().mockReturnValueOnce(firstRefresh.promise).mockResolvedValue(undefined) + const queue = createWorktreeChangeRefreshQueue(handler) + const renamed = { oldWorktreeId: 'wt-old', newWorktreeId: 'wt-new' } + + queue.enqueue({ repoId: 'repo-1' }) + queue.enqueue({ repoId: 'repo-1', renamed }) + queue.enqueue({ repoId: 'repo-1' }) + + expect(handler).toHaveBeenCalledTimes(1) + + firstRefresh.resolve() + await flushPromises() + + expect(handler).toHaveBeenCalledTimes(3) + expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', renamed) + expect(handler).toHaveBeenNthCalledWith(3, 'repo-1', undefined) + }) +}) diff --git a/src/renderer/src/hooks/worktree-change-refresh-queue.ts b/src/renderer/src/hooks/worktree-change-refresh-queue.ts new file mode 100644 index 00000000000..f636a5441cd --- /dev/null +++ b/src/renderer/src/hooks/worktree-change-refresh-queue.ts @@ -0,0 +1,76 @@ +type WorktreeRename = { + oldWorktreeId: string + newWorktreeId: string +} + +type WorktreeChangeEvent = { + repoId: string + renamed?: WorktreeRename +} + +type WorktreeChangeRefreshHandler = (repoId: string, renamed?: WorktreeRename) => Promise + +type QueuedWorktreeChange = { + renamed?: WorktreeRename +} + +type RepoRefreshState = { + running: boolean + queue: QueuedWorktreeChange[] +} + +export type WorktreeChangeRefreshQueue = { + enqueue: (event: WorktreeChangeEvent) => void +} + +export function createWorktreeChangeRefreshQueue( + handler: WorktreeChangeRefreshHandler +): WorktreeChangeRefreshQueue { + const states = new Map() + + const drain = async (repoId: string, state: RepoRefreshState): Promise => { + state.running = true + try { + while (state.queue.length > 0) { + const next = state.queue.shift() + try { + await handler(repoId, next?.renamed) + } catch (error) { + console.error('Failed to refresh changed worktrees:', error) + } + } + } finally { + state.running = false + if (state.queue.length === 0) { + states.delete(repoId) + } else { + void drain(repoId, state) + } + } + } + + return { + enqueue(event) { + let state = states.get(event.repoId) + if (!state) { + state = { running: false, queue: [] } + states.set(event.repoId, state) + } + + if (event.renamed) { + state.queue.push({ renamed: event.renamed }) + } else { + const lastQueued = state.queue.at(-1) + // Why: Windows/OneDrive can emit a burst for one checkout change. Keep a + // trailing refresh, but do not fan out adjacent identical repo scans. + if (!lastQueued || lastQueued.renamed !== undefined) { + state.queue.push({}) + } + } + + if (!state.running) { + void drain(event.repoId, state) + } + } + } +} From 8228155e4d8aef59b6353e7d352d556c5de79a1b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:48:59 -0700 Subject: [PATCH 002/282] Improve preserved branch cleanup after squash merges (#6014) Co-authored-by: Orca --- src/main/git/remove-worktree.test.ts | 84 +++++++++++++ src/main/git/runner.ts | 9 ++ src/main/git/worktree.ts | 6 +- src/relay/git-handler-branch-cleanup.test.ts | 84 +++++++++++++ src/relay/git-handler-branch-cleanup.ts | 3 +- src/relay/git-handler-ops.ts | 2 +- src/relay/git-handler.ts | 47 ++++++- .../sidebar/preserved-branch-toast.test.tsx | 105 ++++++++++++++++ .../sidebar/preserved-branch-toast.tsx | 118 ++++++++++++++++++ .../src/store/slices/store-cascades.test.ts | 25 +--- src/renderer/src/store/slices/worktrees.ts | 37 +----- src/shared/git-branch-cleanup.test.ts | 100 ++++++++++++++- src/shared/git-branch-cleanup.ts | 96 +++++++++++++- 13 files changed, 650 insertions(+), 66 deletions(-) create mode 100644 src/renderer/src/components/sidebar/preserved-branch-toast.test.tsx create mode 100644 src/renderer/src/components/sidebar/preserved-branch-toast.tsx diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index 691ed2cf775..c0c29e3b8cb 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -398,6 +398,90 @@ branch refs/heads/main expect(calls).toContain('git config --remove-section branch.feature/test') }) + it('deletes a squash-merged branch with branch-only merge commits via expected head', async () => { + mockGitCommands({ + 'git worktree list --porcelain -z': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree list --porcelain -z#2': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main +` + }, + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main +` + }, + 'git branch -d -- feature/test': { + error: new Error('branch delete failed'), + stderr: 'error: the branch feature/test is not fully merged' + }, + 'git config --get branch.feature/test.base': { + stdout: 'refs/remotes/origin/main\n' + }, + 'git rev-parse --verify --quiet refs/remotes/origin/main^{commit}': { + stdout: 'target123\n' + }, + 'git merge-tree --write-tree target123 refs/heads/feature/test': { + stdout: 'merged-tree\n' + }, + 'git rev-parse --verify --quiet target123^{tree}': { + stdout: 'target-tree\n' + }, + 'git rev-list --right-only --merges --count target123...refs/heads/feature/test': { + stdout: '1\n' + }, + 'git merge-base target123 refs/heads/feature/test': { + stdout: 'base123\n' + }, + 'git diff base123 refs/heads/feature/test': { + stdout: 'branch net diff\n' + }, + 'git patch-id --stable#1': { + stdout: 'patch123 0000000000000000000000000000000000000000\n' + }, + 'git rev-list --ancestry-path --max-count=201 base123..target123': { + stdout: 'squash123\n' + }, + 'git show --format= squash123': { + stdout: 'squash diff\n' + }, + 'git patch-id --stable#2': { + stdout: 'patch123 squash123\n' + }, + 'git merge-tree --write-tree squash123 refs/heads/feature/test': { + stdout: 'squash-tree\n' + }, + 'git rev-parse --verify --quiet squash123^{tree}': { + stdout: 'squash-tree\n' + } + }) + + await expect(removeWorktree('/repo', '/repo-feature')).resolves.toEqual({}) + + const calls = getGitCalls() + expect(calls).toContain('git update-ref -d refs/heads/feature/test def456') + expect(calls).toContain('git config --remove-section branch.feature/test') + expect(gitExecFileAsyncMock.mock.calls).toContainEqual([ + ['patch-id', '--stable'], + { cwd: '/repo', stdin: 'branch net diff\n' } + ]) + expect(gitExecFileAsyncMock.mock.calls).toContainEqual([ + ['patch-id', '--stable'], + { cwd: '/repo', stdin: 'squash diff\n' } + ]) + }) + it('refreshes the saved remote base before deleting a safe-delete-rejected branch', async () => { mockGitCommands({ 'git worktree list --porcelain -z': { diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 23b57d86327..beef7c5e6be 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -250,6 +250,7 @@ type GitExecOptions = { encoding?: BufferEncoding | 'buffer' maxBuffer?: number timeout?: number + stdin?: string env?: NodeJS.ProcessEnv signal?: AbortSignal wslDistro?: string @@ -313,6 +314,7 @@ function killSpawnedCommandTree(child: ChildProcess): void { type ExecFileCaptureOptions = Omit & { timeout?: number + stdin?: string } function emptyExecFileOutput(options: ExecFileCaptureOptions): string | Buffer { @@ -402,6 +404,12 @@ function execFileCapture( return } + child.once('error', (error) => finish(error)) + + if (options.stdin !== undefined) { + child.stdin?.end(options.stdin) + } + // Why: Node's native execFile timeout waits for the child to exit after // signaling it. Some CLIs ignore that signal, so reject the UI operation // on our own timer and kill the child only as best effort. @@ -740,6 +748,7 @@ export async function gitExecFileAsync( encoding: (options.encoding ?? 'utf-8') as BufferEncoding, maxBuffer: options.maxBuffer, timeout: options.timeout, + stdin: options.stdin, // Why: never let a git read-path call block on an interactive prompt // (issue #5308) — fail fast instead of hanging the runtime. env: policy.env, diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 6b54895782a..714d3189831 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -944,7 +944,11 @@ async function deleteAlreadyMergedBranchAfterSafeDeleteFailure( branchHead: string, options: GitWorktreeExecOptions = {} ): Promise { - const runGit = (args: string[]) => gitExecFileAsync(args, gitExecOptions(repoPath, options)) + const runGit = (args: string[], execOptions?: { stdin?: string }) => + gitExecFileAsync(args, { + ...gitExecOptions(repoPath, options), + ...(execOptions?.stdin !== undefined ? { stdin: execOptions.stdin } : {}) + }) const targetRefs = await getBranchCleanupTargetRefs(runGit, branchName) await refreshBranchCleanupTargetRefs(runGit, targetRefs) // Why: squash merges rewrite commit IDs, so `branch -d` can reject a branch diff --git a/src/relay/git-handler-branch-cleanup.test.ts b/src/relay/git-handler-branch-cleanup.test.ts index 90dcdf003ef..9da1925e9b9 100644 --- a/src/relay/git-handler-branch-cleanup.test.ts +++ b/src/relay/git-handler-branch-cleanup.test.ts @@ -77,6 +77,90 @@ describe('removeWorktreeOp branch cleanup', () => { ) }) + it('deletes a squash-merged SSH branch with branch-only merge commits via expected head', async () => { + let zListCount = 0 + const git = vi.fn(async (args, _cwd, opts) => { + if (args[0] === 'rev-parse' && args[1] === '--git-common-dir') { + return { stdout: '/repo/.git\n', stderr: '' } + } + if (args[0] === 'worktree' && args[1] === 'list' && args.includes('-z')) { + zListCount += 1 + return { + stdout: + zListCount === 1 + ? worktreeList( + { path: '/repo', branch: 'main' }, + { path: '/repo-feature', branch: 'feature/test' } + ) + : worktreeList({ path: '/repo', branch: 'main' }), + stderr: '' + } + } + if (args[0] === 'worktree' && args[1] === 'list') { + return { stdout: worktreeList({ path: '/repo', branch: 'main' }), stderr: '' } + } + if (args[0] === 'branch' && args[1] === '-d') { + throw new Error('error: the branch feature/test is not fully merged') + } + if (args[0] === 'config' && args[1] === '--get') { + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) { + return { stdout: 'target123\n', stderr: '' } + } + if (args[0] === 'merge-tree') { + return { + stdout: args[2] === 'squash123' ? 'squash-tree\n' : 'merged-tree\n', + stderr: '' + } + } + if (args[0] === 'rev-parse' && args.includes('target123^{tree}')) { + return { stdout: 'target-tree\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('squash123^{tree}')) { + return { stdout: 'squash-tree\n', stderr: '' } + } + if (args[0] === 'rev-list' && args.includes('--right-only')) { + return { stdout: '1\n', stderr: '' } + } + if (args[0] === 'merge-base') { + return { stdout: 'base123\n', stderr: '' } + } + if (args[0] === 'diff') { + return { stdout: 'branch net diff\n', stderr: '' } + } + if (args[0] === 'rev-list' && args.includes('--ancestry-path')) { + return { stdout: 'squash123\n', stderr: '' } + } + if (args[0] === 'show') { + return { stdout: 'squash diff\n', stderr: '' } + } + if (args[0] === 'patch-id' && opts?.stdin === 'branch net diff\n') { + return { + stdout: 'patch123 0000000000000000000000000000000000000000\n', + stderr: '' + } + } + if (args[0] === 'patch-id' && opts?.stdin === 'squash diff\n') { + return { stdout: 'patch123 squash123\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + await expect(removeWorktreeOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({}) + + expect(git).toHaveBeenCalledWith( + ['update-ref', '-d', 'refs/heads/feature/test', '1'], + expect.any(String) + ) + expect(git).toHaveBeenCalledWith(['patch-id', '--stable'], expect.any(String), { + stdin: 'branch net diff\n' + }) + expect(git).toHaveBeenCalledWith(['patch-id', '--stable'], expect.any(String), { + stdin: 'squash diff\n' + }) + }) + it('refreshes the saved remote base before deleting a safe-delete-rejected SSH branch', async () => { const calls: { args: string[]; cwd: string }[] = [] let zListCount = 0 diff --git a/src/relay/git-handler-branch-cleanup.ts b/src/relay/git-handler-branch-cleanup.ts index 936178a8d67..7dfa4ed3fb7 100644 --- a/src/relay/git-handler-branch-cleanup.ts +++ b/src/relay/git-handler-branch-cleanup.ts @@ -12,7 +12,8 @@ export async function deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure( branchName: string, branchHead: string ): Promise { - const runGit = (args: string[]) => git(args, repoPath) + const runGit = (args: string[], options?: { stdin?: string }) => + options ? git(args, repoPath, options) : git(args, repoPath) const targetRefs = await getBranchCleanupTargetRefs(runGit, branchName) await refreshBranchCleanupTargetRefs(runGit, targetRefs) // Why: SSH worktrees hit the same squash-merge shape as local worktrees. diff --git a/src/relay/git-handler-ops.ts b/src/relay/git-handler-ops.ts index 6241e71924e..ca6150a110a 100644 --- a/src/relay/git-handler-ops.ts +++ b/src/relay/git-handler-ops.ts @@ -16,7 +16,7 @@ import { readWorkingDiffFile } from './git-working-file-read' export type GitExec = ( args: string[], cwd: string, - opts?: { maxBuffer?: number; disableOptionalLocks?: boolean } + opts?: { maxBuffer?: number; disableOptionalLocks?: boolean; stdin?: string } ) => Promise<{ stdout: string; stderr: string }> export type GitBufferExec = (args: string[], cwd: string) => Promise diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index cbe91ce3bee..46dd750f2a0 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines -- Why: this relay handler centralizes the git RPC protocol surface so local and SSH git behavior stay in one dispatch table. */ -import { execFile, spawn } from 'child_process' +import { execFile, spawn, type ExecFileOptions } from 'child_process' import { promisify } from 'util' import * as path from 'path' import type { RelayDispatcher, RequestContext } from './dispatcher' @@ -51,6 +51,41 @@ const execFileAsync = promisify(execFile) const MAX_GIT_BUFFER = 10 * 1024 * 1024 const BULK_CHUNK_SIZE = 100 +function execFileWithStdin( + command: string, + args: string[], + options: ExecFileOptions, + stdin: string +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + let settled = false + const finish = ( + error: Error | null, + stdout: string | Buffer = '', + stderr: string | Buffer = '' + ): void => { + if (settled) { + return + } + settled = true + if (error) { + reject(Object.assign(error, { stdout, stderr })) + return + } + resolve({ stdout: String(stdout), stderr: String(stderr) }) + } + const child = execFile(command, args, options, (error, stdout, stderr) => { + if (error) { + finish(error, stdout, stderr) + return + } + finish(null, stdout, stderr) + }) + child.once('error', (error) => finish(error)) + child.stdin?.end(stdin) + }) +} + export class GitHandler { private dispatcher: RelayDispatcher @@ -114,6 +149,7 @@ export class GitHandler { disableOptionalLocks?: boolean signal?: AbortSignal nonInteractive?: boolean + stdin?: string } ): Promise<{ stdout: string; stderr: string }> { const env = buildRelayCommandEnv() @@ -126,13 +162,18 @@ export class GitHandler { env.SSH_ASKPASS = '' env.GIT_SSH_COMMAND ??= 'ssh -o BatchMode=yes' } - return execFileAsync('git', args, { + const execOptions = { cwd: expandTilde(cwd), env, encoding: 'utf-8', maxBuffer: opts?.maxBuffer ?? MAX_GIT_BUFFER, signal: opts?.signal - }) + } satisfies ExecFileOptions + if (opts?.stdin !== undefined) { + return execFileWithStdin('git', args, execOptions, opts.stdin) + } + const { stdout, stderr } = await execFileAsync('git', args, execOptions) + return { stdout: String(stdout), stderr: String(stderr) } } private async gitBuffer(args: string[], cwd: string): Promise { diff --git a/src/renderer/src/components/sidebar/preserved-branch-toast.test.tsx b/src/renderer/src/components/sidebar/preserved-branch-toast.test.tsx new file mode 100644 index 00000000000..24f768af4af --- /dev/null +++ b/src/renderer/src/components/sidebar/preserved-branch-toast.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import type { RemoveWorktreeResult } from '../../../../shared/types' +import { showPreservedBranchToast } from './preserved-branch-toast' + +vi.mock('sonner', () => ({ + toast: { + warning: vi.fn(), + dismiss: vi.fn() + } +})) + +const mountedRoots: Root[] = [] + +function renderToastBody(): HTMLElement { + const description = vi.mocked(toast.warning).mock.calls.at(-1)?.[1] + ?.description as React.ReactElement + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + act(() => { + root.render(description) + }) + return container +} + +function clickButton(container: HTMLElement, label: string): void { + const button = [...container.querySelectorAll('button')].find( + (el) => el.textContent?.trim() === label + ) + if (!button) { + throw new Error(`button "${label}" not found`) + } + act(() => { + button.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) +} + +afterEach(() => { + mountedRoots.splice(0).forEach((root) => act(() => root.unmount())) + document.body.innerHTML = '' + vi.clearAllMocks() +}) + +describe('showPreservedBranchToast', () => { + it('renders the branch recovery action below the long description', () => { + const onForceDelete = vi.fn() + const result: RemoveWorktreeResult = { + preservedBranch: { + branchName: 'feat/notes-send-any-running-agent', + head: 'abc123' + } + } + + showPreservedBranchToast( + result, + { + displayName: 'Send review notes to any running agent of a worktree', + isMainWorktree: false + }, + onForceDelete + ) + const body = renderToastBody() + + expect(toast.warning).toHaveBeenCalledWith( + 'Worktree deleted, branch kept', + expect.objectContaining({ + id: 'preserved-branch:feat/notes-send-any-running-agent:abc123', + dismissible: true, + duration: Infinity + }) + ) + expect(body.textContent).toContain('feat/notes-send-any-running-agent') + expect(body.textContent).toContain('Send review notes to any running agent of a worktree') + + clickButton(body, 'Force Delete Branch') + + expect(onForceDelete).toHaveBeenCalledWith('feat/notes-send-any-running-agent', 'abc123') + expect(toast.dismiss).toHaveBeenCalledWith( + 'preserved-branch:feat/notes-send-any-running-agent:abc123' + ) + }) + + it('does not show the force-delete action without the preserved head', () => { + const result: RemoveWorktreeResult = { + preservedBranch: { + branchName: 'feature/test' + } + } + + showPreservedBranchToast(result, undefined, vi.fn()) + const body = renderToastBody() + + expect(body.textContent).not.toContain('Force Delete Branch') + expect(toast.warning).toHaveBeenCalledWith( + 'Worktree deleted, branch kept', + expect.not.objectContaining({ duration: Infinity }) + ) + }) +}) diff --git a/src/renderer/src/components/sidebar/preserved-branch-toast.tsx b/src/renderer/src/components/sidebar/preserved-branch-toast.tsx new file mode 100644 index 00000000000..b7b343aaceb --- /dev/null +++ b/src/renderer/src/components/sidebar/preserved-branch-toast.tsx @@ -0,0 +1,118 @@ +import { Trash2 } from 'lucide-react' +import { toast } from 'sonner' +import type { RemoveWorktreeResult, Worktree } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { Button } from '../ui/button' + +type PreservedBranchWorktree = Pick + +type PreservedBranchToastBodyProps = { + description: string + forceDeleteLabel: string | undefined + onForceDelete: (() => void) | undefined +} + +function preservedBranchToastId(branchName: string, expectedHead: string | undefined): string { + return `preserved-branch:${branchName}:${expectedHead ?? 'unknown'}` +} + +function getPreservedBranchTitle(isWorkspace: boolean): string { + return isWorkspace + ? translate('auto.store.slices.worktrees.5366d13eec', 'Workspace deleted, branch kept') + : translate('auto.store.slices.worktrees.2e17f825d4', 'Worktree deleted, branch kept') +} + +function getPreservedBranchDescription( + branch: string, + targetName: string | undefined, + isWorkspace: boolean +): string { + if (!targetName) { + return translate( + 'auto.store.slices.worktrees.78e08cd877', + 'Git could not safely delete branch "{{value0}}", so Orca kept it to avoid losing local commits.', + { value0: branch } + ) + } + return isWorkspace + ? translate( + 'auto.store.slices.worktrees.3b57982bf6', + 'Git could not safely delete branch "{{value0}}" after deleting workspace "{{value1}}", so Orca kept it to avoid losing local commits.', + { value0: branch, value1: targetName } + ) + : translate( + 'auto.store.slices.worktrees.81f13f48d2', + 'Git could not safely delete branch "{{value0}}" after deleting worktree "{{value1}}", so Orca kept it to avoid losing local commits.', + { value0: branch, value1: targetName } + ) +} + +// Why: Sonner's native action row pinches long branch/worktree names into a +// narrow column. Keep the native toast frame, but give the body its own footer. +function PreservedBranchToastBody({ + description, + forceDeleteLabel, + onForceDelete +}: PreservedBranchToastBodyProps): React.JSX.Element { + return ( +
+

+ {description} +

+ {forceDeleteLabel && onForceDelete ? ( +
+ +
+ ) : null} +
+ ) +} + +export function showPreservedBranchToast( + result: RemoveWorktreeResult | undefined, + worktree: PreservedBranchWorktree | undefined, + onForceDelete: (branchName: string, expectedHead: string) => void +): void { + const preservedBranch = result?.preservedBranch + const branch = preservedBranch?.branchName + if (!branch) { + return + } + + const isWorkspace = worktree?.isMainWorktree === true + const targetName = worktree?.displayName?.trim() + const expectedHead = preservedBranch.head + const toastId = preservedBranchToastId(branch, expectedHead) + const forceDeleteLabel = expectedHead + ? translate('auto.store.slices.worktrees.e50495aae6', 'Force Delete Branch') + : undefined + const description = getPreservedBranchDescription(branch, targetName, isWorkspace) + const forceDelete = expectedHead + ? (): void => { + onForceDelete(branch, expectedHead) + toast.dismiss(toastId) + } + : undefined + + toast.warning(getPreservedBranchTitle(isWorkspace), { + id: toastId, + description: ( + + ), + dismissible: true, + ...(expectedHead ? { duration: Infinity } : {}) + }) +} diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index b258ac0f33a..663b4df976d 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -199,27 +199,10 @@ describe('removeWorktree cascade', () => { preservedBranch: { branchName: 'feature/test', head: 'def456' } }) expect(toast.warning).toHaveBeenCalledWith('Worktree deleted, branch kept', { - description: - 'Git could not safely delete branch "feature/test" after deleting worktree "Review cleanup", so Orca kept it to avoid losing local commits.', - action: { - label: 'Force Delete Branch', - onClick: expect.any(Function) - } - }) - - const action = vi.mocked(toast.warning).mock.calls.at(-1)?.[1]?.action as - | { onClick?: () => void } - | undefined - action?.onClick?.() - await vi.waitFor(() => { - expect(mockApi.worktrees.forceDeletePreservedBranch).toHaveBeenCalledWith({ - worktreeId, - branchName: 'feature/test', - expectedHead: 'def456' - }) - }) - expect(toast.success).toHaveBeenCalledWith('Local branch deleted', { - description: 'Deleted "feature/test".' + id: 'preserved-branch:feature/test:def456', + description: expect.anything(), + dismissible: true, + duration: Infinity }) }) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 2153ee068f0..bb4e9031f3e 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -40,6 +40,7 @@ import { requestVirtualizedScrollAnchorRecord } from '@/hooks/requestVirtualized import { branchName } from '@/lib/git-utils' import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler' import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast' +import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast' import { translate } from '@/i18n/i18n' import { getRepoExecutionHostId, @@ -149,42 +150,6 @@ function showLocalBaseRefRefreshToast(result: LocalBaseRefRefreshResult | undefi ) } -function showPreservedBranchToast( - result: RemoveWorktreeResult | undefined, - worktree: Pick | undefined, - onForceDelete: (branchName: string, expectedHead: string) => void -): void { - const preservedBranch = result?.preservedBranch - const branch = preservedBranch?.branchName - if (!branch) { - return - } - const targetTitle = worktree?.isMainWorktree ? 'Workspace' : 'Worktree' - const targetLabel = targetTitle.toLowerCase() - const targetName = worktree?.displayName?.trim() - const deletedTarget = targetName ? ` after deleting ${targetLabel} "${targetName}"` : '' - const expectedHead = preservedBranch.head - const action = expectedHead - ? { - label: translate('auto.store.slices.worktrees.e50495aae6', 'Force Delete Branch'), - onClick: () => onForceDelete(branch, expectedHead) - } - : undefined - toast.warning( - translate('auto.store.slices.worktrees.4e6496f3d2', '{{value0}} deleted, branch kept', { - value0: targetTitle - }), - { - description: translate( - 'auto.store.slices.worktrees.d1d78a7baa', - 'Git could not safely delete branch "{{value0}}"{{value1}}, so Orca kept it to avoid losing local commits.', - { value0: branch, value1: deletedTarget } - ), - ...(action ? { action } : {}) - } - ) -} - function arraysShallowEqual(a: string[] | undefined, b: string[] | undefined): boolean { if (a === b) { return true diff --git a/src/shared/git-branch-cleanup.test.ts b/src/shared/git-branch-cleanup.test.ts index 6d5415ee9a0..6728bf8973e 100644 --- a/src/shared/git-branch-cleanup.test.ts +++ b/src/shared/git-branch-cleanup.test.ts @@ -1,5 +1,50 @@ import { describe, expect, it, vi } from 'vitest' -import { refreshBranchCleanupTargetRefs, type GitBranchCleanupExec } from './git-branch-cleanup' +import { + branchHasNoUnmergedChangesOnAnyTarget, + refreshBranchCleanupTargetRefs, + type GitBranchCleanupExec +} from './git-branch-cleanup' + +function baseProofResponses( + responses: Partial> = {} +): GitBranchCleanupExec { + return vi.fn(async (args, options) => { + if (args[0] === 'patch-id' && options?.stdin === 'branch-diff') { + const response = responses.branchPatchId ?? 'branch-patch 0000000\n' + if (response instanceof Error) { + throw response + } + return { stdout: response } + } + if (args[0] === 'patch-id' && options?.stdin === 'squash-diff') { + const response = responses.squashPatchId ?? 'branch-patch squash\n' + if (response instanceof Error) { + throw response + } + return { stdout: response } + } + const key = args.join(' ') + const response = + responses[key] ?? + { + 'rev-parse --verify --quiet refs/remotes/origin/main^{commit}': 'target\n', + 'merge-tree --write-tree target refs/heads/feature/test': 'merged-tree\n', + 'rev-parse --verify --quiet target^{tree}': 'target-tree\n', + 'rev-list --right-only --merges --count target...refs/heads/feature/test': '1\n', + 'merge-base target refs/heads/feature/test': 'base\n', + 'diff base refs/heads/feature/test': 'branch-diff', + 'rev-list --ancestry-path --max-count=201 base..target': 'squash\n', + 'show --format= squash': 'squash-diff', + 'merge-tree --write-tree squash refs/heads/feature/test': 'squash-tree\n', + 'rev-parse --verify --quiet squash^{tree}': 'squash-tree\n' + }[key] ?? + '' + if (response instanceof Error) { + throw response + } + return { stdout: response } + }) +} describe('refreshBranchCleanupTargetRefs', () => { it('fetches each remote-tracking target remote once and prefers slashed remote names', async () => { @@ -43,3 +88,56 @@ describe('refreshBranchCleanupTargetRefs', () => { ).resolves.toBeUndefined() }) }) + +describe('branchHasNoUnmergedChangesOnAnyTarget', () => { + it('accepts a branch with merge commits when a target squash commit matches its net patch', async () => { + const runGit = baseProofResponses() + + await expect( + branchHasNoUnmergedChangesOnAnyTarget(runGit, 'feature/test', ['refs/remotes/origin/main']) + ).resolves.toBe(true) + + expect(runGit).toHaveBeenCalledWith(['patch-id', '--stable'], { stdin: 'branch-diff' }) + expect(runGit).toHaveBeenCalledWith(['patch-id', '--stable'], { stdin: 'squash-diff' }) + expect(runGit).not.toHaveBeenCalledWith(['cherry', '-v', 'target', 'refs/heads/feature/test']) + }) + + it('preserves a branch with merge commits when no target squash commit matches', async () => { + const runGit = baseProofResponses({ squashPatchId: 'other-patch squash\n' }) + + await expect( + branchHasNoUnmergedChangesOnAnyTarget(runGit, 'feature/test', ['refs/remotes/origin/main']) + ).resolves.toBe(false) + }) + + it('preserves when a matching squash candidate still changes after merging the branch', async () => { + const runGit = baseProofResponses({ + 'merge-tree --write-tree squash refs/heads/feature/test': 'different-tree\n' + }) + + await expect( + branchHasNoUnmergedChangesOnAnyTarget(runGit, 'feature/test', ['refs/remotes/origin/main']) + ).resolves.toBe(false) + }) + + it('preserves when the target squash scan exceeds the cap', async () => { + const commits = Array.from({ length: 201 }, (_, index) => `commit-${index}`).join('\n') + const runGit = baseProofResponses({ + 'rev-list --ancestry-path --max-count=201 base..target': `${commits}\n` + }) + + await expect( + branchHasNoUnmergedChangesOnAnyTarget(runGit, 'feature/test', ['refs/remotes/origin/main']) + ).resolves.toBe(false) + + expect(runGit).not.toHaveBeenCalledWith(['show', '--format=', 'commit-0']) + }) + + it('preserves when patch-id cannot be computed', async () => { + const runGit = baseProofResponses({ branchPatchId: new Error('patch-id failed') }) + + await expect( + branchHasNoUnmergedChangesOnAnyTarget(runGit, 'feature/test', ['refs/remotes/origin/main']) + ).resolves.toBe(false) + }) +}) diff --git a/src/shared/git-branch-cleanup.ts b/src/shared/git-branch-cleanup.ts index da77f053ce8..3e177bc6cdc 100644 --- a/src/shared/git-branch-cleanup.ts +++ b/src/shared/git-branch-cleanup.ts @@ -1,12 +1,30 @@ -export type GitBranchCleanupExec = (argv: string[]) => Promise<{ stdout: string }> +export type GitBranchCleanupExec = ( + argv: string[], + options?: { stdin?: string } +) => Promise<{ stdout: string }> + +const SQUASH_PATCH_SCAN_LIMIT = 200 async function readOptionalGitStdout( + runGit: GitBranchCleanupExec, + argv: string[], + options?: { stdin?: string } +): Promise { + try { + const { stdout } = await runGit(argv, options) + return stdout.trim() || null + } catch { + return null + } +} + +async function readOptionalGitRawStdout( runGit: GitBranchCleanupExec, argv: string[] ): Promise { try { const { stdout } = await runGit(argv) - return stdout.trim() || null + return stdout || null } catch { return null } @@ -117,6 +135,77 @@ async function branchOnlyCommitsArePatchEquivalent( return lines.every((line) => line.startsWith('-')) } +function parsePatchId(stdout: string | null): string | null { + const line = stdout + ?.split(/\r?\n/) + .map((candidate) => candidate.trim()) + .find(Boolean) + const patchId = line?.split(/\s+/)[0] + return patchId || null +} + +async function computeStablePatchId( + runGit: GitBranchCleanupExec, + patchText: string | null +): Promise { + if (!patchText) { + return null + } + return parsePatchId( + await readOptionalGitStdout(runGit, ['patch-id', '--stable'], { stdin: patchText }) + ) +} + +async function branchNetPatchMatchesTargetSquashCommit( + runGit: GitBranchCleanupExec, + targetOid: string, + branchRef: string +): Promise { + const mergeBase = await readOptionalGitStdout(runGit, ['merge-base', targetOid, branchRef]) + if (!mergeBase) { + return false + } + + const branchPatchId = await computeStablePatchId( + runGit, + await readOptionalGitRawStdout(runGit, ['diff', mergeBase, branchRef]) + ) + if (!branchPatchId) { + return false + } + + const commits = ( + await readOptionalGitStdout(runGit, [ + 'rev-list', + '--ancestry-path', + `--max-count=${SQUASH_PATCH_SCAN_LIMIT + 1}`, + `${mergeBase}..${targetOid}` + ]) + ) + ?.split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + if (!commits?.length || commits.length > SQUASH_PATCH_SCAN_LIMIT) { + return false + } + + for (const commitOid of commits) { + const commitPatchId = await computeStablePatchId( + runGit, + await readOptionalGitRawStdout(runGit, ['show', '--format=', commitOid]) + ) + // Why: a matching patch-id identifies a possible squash commit, but the + // tree merge proves the branch contributes no additional changes there. + if ( + commitPatchId === branchPatchId && + (await branchMergesWithoutTreeChanges(runGit, commitOid, branchRef)) + ) { + return true + } + } + return false +} + export async function branchHasNoUnmergedChangesOnAnyTarget( runGit: GitBranchCleanupExec, branchName: string, @@ -133,6 +222,9 @@ export async function branchHasNoUnmergedChangesOnAnyTarget( return true } if (await hasBranchOnlyMergeCommits(runGit, targetOid, branchRef)) { + if (await branchNetPatchMatchesTargetSquashCommit(runGit, targetOid, branchRef)) { + return true + } continue } if (await branchOnlyCommitsArePatchEquivalent(runGit, targetOid, branchRef)) { From 36bdd06aa87b34aa738c8b79ebb474615c3814d7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:51:44 -0700 Subject: [PATCH 003/282] Prevent duplicate startup command echoes (#6023) Co-authored-by: Orca --- src/main/daemon/daemon-pty-adapter.test.ts | 21 ++++ src/main/daemon/daemon-pty-adapter.ts | 41 ++++-- src/main/daemon/daemon-server.ts | 3 +- src/main/daemon/post-ready-flush-gate.test.ts | 17 +++ src/main/daemon/post-ready-flush-gate.ts | 20 +-- src/main/daemon/session.test.ts | 119 ++++++++++++++++++ src/main/daemon/session.ts | 86 +++++++++---- src/main/daemon/terminal-host.test.ts | 48 +++++++ src/main/daemon/terminal-host.ts | 20 ++- src/main/daemon/types.ts | 9 +- src/main/ipc/pty.test.ts | 36 +++++- src/main/providers/local-pty-provider.test.ts | 77 +++++++++++- src/main/providers/local-pty-provider.ts | 66 +++++++--- .../providers/local-pty-shell-ready.test.ts | 91 ++++++++++++-- src/main/providers/local-pty-shell-ready.ts | 91 ++++---------- src/main/shell-ready-marker-scanner.ts | 69 ++++++++++ 16 files changed, 671 insertions(+), 143 deletions(-) create mode 100644 src/main/shell-ready-marker-scanner.ts diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index bf8948bb12b..c140a42ccf1 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -795,6 +795,27 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(true) }) + it('persists final take records that are not represented in the snapshot', async () => { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + const { id } = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + command: 'printf ready', + env: { SHELL: '/bin/zsh' }, + sessionId: 'sleep-checkpoint-tail' + }) + const appendSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'appendIncrements') + + lastSubprocess._simulateData('\x1b]777;orca-shell-ready') + await historyAdapter.shutdown(id, { immediate: true, keepHistory: true }) + + expect(appendSpy).toHaveBeenCalledWith(id, expect.any(Number), [ + { kind: 'output', data: '\x1b]777;orca-shell-ready' } + ]) + }) + it('returns cold restore data when disk history has unclean shutdown', async () => { // Simulate a previous daemon crash: write history files without endedAt const sessionId = 'cold-restore-test' diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 7357ecbd1f4..b2daef2a7a9 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -297,7 +297,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // Why: sleep/exact-stop must preserve restorable terminal history, // so force a final checkpoint before killing the daemon session. if (opts.keepHistory) { - await this.checkpointSessions([id], { final: true }) + await this.checkpointSessions([id], { final: true, teardown: true }) } await this.client.request('kill', { sessionId: id, immediate: opts.immediate ?? false }) this.activeSessionIds.delete(id) @@ -675,7 +675,9 @@ export class DaemonPtyAdapter implements IPtyProvider { // socket per session. Returns a promise that resolves when all checkpoint // writes complete (callers that don't need to wait can void it). // Why final=true here: this runs on clean disconnect, where the full-depth - // snapshot (not the increment log) must be the restore source. + // snapshot (not the increment log) must be the restore source. It is not a + // teardown snapshot: the detached daemon and its PTYs keep running for warm + // reattach, so shell-ready scanner state must remain intact. private async checkpointAllSessions(): Promise { const completed = await this.checkpointSessions(this.activeSessionIds, { final: true }) for (const sessionId of completed) { @@ -685,7 +687,7 @@ export class DaemonPtyAdapter implements IPtyProvider { private async checkpointSessions( sessionIds: Iterable, - opts?: { final?: boolean } + opts?: { final?: boolean; teardown?: boolean } ): Promise> { const completed = new Set() if (!this.historyManager) { @@ -702,7 +704,10 @@ export class DaemonPtyAdapter implements IPtyProvider { return } const sessionId = ids[index] - await this.checkpointSession(sessionId, opts?.final === true) + await this.checkpointSession(sessionId, { + final: opts?.final === true, + teardown: opts?.teardown === true + }) .then(() => { completed.add(sessionId) }) @@ -719,7 +724,10 @@ export class DaemonPtyAdapter implements IPtyProvider { return completed } - private async checkpointSession(sessionId: string, final: boolean): Promise { + private async checkpointSession( + sessionId: string, + opts: { final: boolean; teardown: boolean } + ): Promise { if (!this.supportsIncrementalCheckpoints) { const result = await this.client.request('getSnapshot', { sessionId }) if (result.snapshot && this.historyManager) { @@ -727,14 +735,14 @@ export class DaemonPtyAdapter implements IPtyProvider { } return } - if (final || this.sessionsNeedingFullCheckpoint.has(sessionId)) { + if (opts.final || this.sessionsNeedingFullCheckpoint.has(sessionId)) { // Why take-with-snapshot instead of plain getSnapshot: the take clears // the daemon's pending records in the same synchronous turn as the // serialize. A plain snapshot would leave pre-snapshot records pending; // a later warm reattach would append them to the fresh log and cold // restore would replay them on top of a checkpoint that already // contains them. - await this.takeSnapshotAndCheckpoint(sessionId) + await this.takeSnapshotAndCheckpoint(sessionId, { teardown: opts.teardown }) this.sessionsNeedingFullCheckpoint.delete(sessionId) return } @@ -747,7 +755,7 @@ export class DaemonPtyAdapter implements IPtyProvider { if (take.overflowed) { // Why: overflow dropped records, so the log has a hole — only a full // snapshot (which reflects everything ever written) can re-anchor it. - await this.takeSnapshotAndCheckpoint(sessionId) + await this.takeSnapshotAndCheckpoint(sessionId, { teardown: false }) return } if (take.records.length === 0) { @@ -764,17 +772,28 @@ export class DaemonPtyAdapter implements IPtyProvider { if (appendResult === 'needs-checkpoint') { // Why dropping take.records is lossless: they were applied to the live // emulator before the take, so the snapshot below contains them. - await this.takeSnapshotAndCheckpoint(sessionId) + await this.takeSnapshotAndCheckpoint(sessionId, { teardown: false }) } } - private async takeSnapshotAndCheckpoint(sessionId: string): Promise { + private async takeSnapshotAndCheckpoint( + sessionId: string, + opts: { teardown: boolean } + ): Promise { const take = await this.client.request('takePendingOutput', { sessionId, - includeSnapshot: true + includeSnapshot: true, + teardownSnapshot: opts.teardown }) if (take?.snapshot && this.historyManager) { await this.historyManager.checkpoint(sessionId, take.snapshot) + if (take.records.length > 0) { + // Why: take-with-snapshot usually returns no records because the + // snapshot subsumes them. Held parser-state bytes, such as an + // incomplete shell-ready marker prefix, are not representable in the + // snapshot and must remain as a post-checkpoint log tail. + await this.historyManager.appendIncrements(sessionId, take.seq, take.records) + } } } diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index af1afebb54e..1f37ce73a9a 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -384,7 +384,8 @@ export class DaemonServer { // those bytes on top of a snapshot that already contains them. return this.host.takePendingOutput( request.payload.sessionId, - request.payload.includeSnapshot === true + request.payload.includeSnapshot === true, + { teardownSnapshot: request.payload.teardownSnapshot === true } ) case 'ping': diff --git a/src/main/daemon/post-ready-flush-gate.test.ts b/src/main/daemon/post-ready-flush-gate.test.ts index 49379a53bad..f8e58ba7c7d 100644 --- a/src/main/daemon/post-ready-flush-gate.test.ts +++ b/src/main/daemon/post-ready-flush-gate.test.ts @@ -34,6 +34,15 @@ describe('PostReadyFlushGate', () => { expect(onFlush).toHaveBeenCalledTimes(1) }) + it('flushes via short delay when arm receives post-marker bytes evidence', () => { + gate.arm(true) + vi.advanceTimersByTime(POST_READY_FLUSH_DELAY_MS - 1) + expect(onFlush).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + expect(onFlush).toHaveBeenCalledTimes(1) + }) + it('flushes via wall-clock fallback when no notifyData arrives', () => { gate.arm() @@ -41,6 +50,14 @@ describe('PostReadyFlushGate', () => { expect(onFlush).toHaveBeenCalledTimes(1) }) + it('does not fallback at the old duplicate-echo race window', () => { + gate.arm() + + vi.advanceTimersByTime(50) + + expect(onFlush).not.toHaveBeenCalled() + }) + it('ignores notifyData before arm()', () => { gate.notifyData() vi.advanceTimersByTime(1000) diff --git a/src/main/daemon/post-ready-flush-gate.ts b/src/main/daemon/post-ready-flush-gate.ts index 2e15b184068..199bb601024 100644 --- a/src/main/daemon/post-ready-flush-gate.ts +++ b/src/main/daemon/post-ready-flush-gate.ts @@ -9,17 +9,17 @@ * redraws it under the prompt — producing a visible duplicate (e.g. "claude" * appears twice on agent launch). * - * Strategy: after arm() is called, wait for the next PTY data chunk (the - * prompt draw) plus a short delay for the tcsetattr() that enables raw mode. - * A wall-clock fallback covers the case where the prompt arrives in the same - * chunk as the marker, so no follow-up notifyData() ever fires. + * Strategy: after arm() is called, wait for prompt bytes plus a short delay + * for the tcsetattr() that enables raw mode. If the marker-completing scan + * already saw post-marker bytes, use that same short path immediately. + * A conservative wall-clock fallback covers ambiguous marker-only cases. * * Mirrors the gate in local-pty-shell-ready.ts::writeStartupCommandWhenShellReady, * which solves the same race on the non-daemon path. */ export const POST_READY_FLUSH_DELAY_MS = 30 -export const POST_READY_FLUSH_FALLBACK_MS = 50 +export const POST_READY_FLUSH_FALLBACK_MS = 200 export class PostReadyFlushGate { private awaitingPromptDraw = false @@ -35,10 +35,14 @@ export class PostReadyFlushGate { } /** Arm the gate after observing the shell-ready marker. Starts the - * wall-clock fallback; the flush fires when the fallback elapses or when - * notifyData() observes a subsequent PTY data chunk. */ - arm(): void { + * wall-clock fallback unless the marker scan already observed post-marker + * bytes, in which case the short post-data settle path is enough. */ + arm(postMarkerBytesObserved = false): void { this.awaitingPromptDraw = true + if (postMarkerBytesObserved) { + this.notifyData() + return + } this.fallbackTimer = setTimeout(() => { this.fallbackTimer = null this.awaitingPromptDraw = false diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index 4d8f4e746bb..fb5b961cc98 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -207,6 +207,125 @@ describe('Session', () => { expect(subprocess.written).toEqual(['first\n', 'second\n']) }) + it('uses the short settle path when marker and prompt bytes arrive together', () => { + createSession({ shellReadySupported: true }) + session.write('codex\n') + + subprocess.simulateData('\x1b]777;orca-shell-ready\x07\r\nuser@host $ ') + expect(session.shellState).toBe('ready' satisfies ShellReadyState) + vi.advanceTimersByTime(29) + expect(subprocess.written).toEqual([]) + + vi.advanceTimersByTime(1) + expect(subprocess.written).toEqual(['codex\n']) + }) + + it('does not treat bytes before the marker as post-marker prompt output', () => { + createSession({ shellReadySupported: true }) + session.write('codex\n') + + subprocess.simulateData('last login\r\n\x1b]777;orca-shell-ready\x07') + expect(session.shellState).toBe('ready' satisfies ShellReadyState) + vi.advanceTimersByTime(30) + expect(subprocess.written).toEqual([]) + + subprocess.simulateData('\r\nuser@host $ ') + vi.advanceTimersByTime(30) + expect(subprocess.written).toEqual(['codex\n']) + }) + + it('strips shell-ready marker bytes before client and pending-output fan-out', () => { + createSession({ shellReadySupported: true }) + const received: string[] = [] + session.attachClient({ + onData: (data) => received.push(data), + onExit: () => {} + }) + + subprocess.simulateData('hello \x1b]777;orca-shell-ready\x07% ') + + expect(received).toEqual(['hello % ']) + expect(session.takePendingOutput(false)?.records).toEqual([ + { kind: 'output', data: 'hello % ' } + ]) + expect(session.getSnapshot()?.snapshotAnsi).toContain('hello % ') + expect(session.getSnapshot()?.snapshotAnsi).not.toContain('orca-shell-ready') + }) + + it('releases held marker-prefix bytes before flushing queued input on timeout', () => { + createSession({ shellReadySupported: true, shellReadyTimeoutMs: 100 }) + const received: string[] = [] + session.attachClient({ + onData: (data) => received.push(data), + onExit: () => {} + }) + + subprocess.simulateData('\x1b]777;orca-shell-ready') + session.write('codex\n') + vi.advanceTimersByTime(100) + + expect(session.shellState).toBe('timed_out' satisfies ShellReadyState) + expect(received).toEqual(['\x1b]777;orca-shell-ready']) + expect(session.takePendingOutput(false)?.records).toEqual([ + { kind: 'output', data: '\x1b]777;orca-shell-ready' } + ]) + expect(subprocess.written).toEqual(['codex\n']) + }) + + it('releases held marker-prefix bytes when the subprocess exits before readiness', () => { + createSession({ shellReadySupported: true, shellReadyTimeoutMs: 100 }) + const received: string[] = [] + session.attachClient({ + onData: (data) => received.push(data), + onExit: () => {} + }) + + subprocess.simulateData('\x1b]777;orca-shell-ready') + subprocess.simulateExit(0) + + expect(received).toEqual(['\x1b]777;orca-shell-ready']) + expect(session.takePendingOutput(false)?.records).toEqual([ + { kind: 'output', data: '\x1b]777;orca-shell-ready' } + ]) + }) + + it('keeps held marker-prefix bytes during live take-with-snapshot', () => { + createSession({ shellReadySupported: true, shellReadyTimeoutMs: 100 }) + session.write('codex\n') + + subprocess.simulateData('\x1b]777;orca-shell-ready') + const taken = session.takePendingOutput(true) + subprocess.simulateData('\x07\r\nuser@host $ ') + vi.advanceTimersByTime(30) + + expect(taken?.records).toEqual([]) + expect(taken?.snapshot).toBeTruthy() + expect(session.shellState).toBe('ready' satisfies ShellReadyState) + expect(subprocess.written).toEqual(['codex\n']) + }) + + it('releases held marker-prefix bytes before final take-with-snapshot', () => { + createSession({ shellReadySupported: true, shellReadyTimeoutMs: 100 }) + + subprocess.simulateData('\x1b]777;orca-shell-ready') + const taken = session.takePendingOutput(true, { teardownSnapshot: true }) + + expect(taken?.records).toEqual([{ kind: 'output', data: '\x1b]777;orca-shell-ready' }]) + expect(taken?.snapshot).toBeTruthy() + }) + + it('cancels the post-ready flush gate when force-disposing the subprocess', () => { + createSession({ shellReadySupported: true }) + session.write('codex\n') + + subprocess.simulateData('\x1b]777;orca-shell-ready\x07') + expect(session.shellState).toBe('ready' satisfies ShellReadyState) + session.forceKillAndDisposeSubprocess() + vi.advanceTimersByTime(500) + + expect(subprocess.written).toEqual([]) + }) + it('transitions to timed_out after 15 seconds', () => { createSession({ shellReadySupported: true }) session.write('waiting input') diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 7f2dee7416f..dd6f227196b 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -2,6 +2,12 @@ import { HeadlessEmulator } from './headless-emulator' import { isValidPtySize, normalizePtySize } from './daemon-pty-size' import { PostReadyFlushGate } from './post-ready-flush-gate' +import { + createShellReadyScanState, + drainShellReadyHeldBytes, + scanForShellReady, + type ShellReadyScanState +} from '../shell-ready-marker-scanner' import type { PendingOutputRecord, SessionState, @@ -15,7 +21,6 @@ const SHELL_READY_TIMEOUT_MS = 15_000 // older daemon/local paths that still report shell-ready support for Codex. export const CODEX_SHELL_READY_TIMEOUT_MS = 300 const KILL_TIMEOUT_MS = 5_000 -const SHELL_READY_MARKER = '\x1b]777;orca-shell-ready\x07' // Why: pending records exist so the 5s checkpoint can persist increments // instead of re-serializing the whole buffer. If no client drains them (main // process gone, history disabled), memory must stay bounded — past the cap we @@ -81,7 +86,7 @@ export class Session { private readonly onSessionExit?: (code: number) => void private attachedClients: AttachedClient[] = [] private preReadyStdinQueue: string[] = [] - private markerBuffer = '' + private shellReadyScanState: ShellReadyScanState | null = null private shellReadyTimer: ReturnType | null = null private killTimer: ReturnType | null = null private postReadyFlushGate: PostReadyFlushGate @@ -107,6 +112,7 @@ export class Session { if (opts.shellReadySupported) { this._shellState = 'pending' + this.shellReadyScanState = createShellReadyScanState() this.shellReadyTimer = setTimeout(() => { this.onShellReadyTimeout() }, opts.shellReadyTimeoutMs ?? SHELL_READY_TIMEOUT_MS) @@ -224,10 +230,15 @@ export class Session { * when includeSnapshot is set, the serialize happens in the same turn so no * PTY data can land between the drain and the snapshot (which would later * be replayed twice on cold restore). */ - takePendingOutput(includeSnapshot: boolean): TakePendingOutputResult | null { + takePendingOutput( + includeSnapshot: boolean, + opts: { teardownSnapshot?: boolean } = {} + ): TakePendingOutputResult | null { if (this._disposed) { return null } + const releasedHeldBytes = + includeSnapshot && opts.teardownSnapshot === true ? this.prepareForFinalSnapshot() : '' const records = this.pendingOutputRecords const overflowed = this.pendingOutputOverflowed this.pendingOutputRecords = [] @@ -235,7 +246,11 @@ export class Session { this.pendingOutputOverflowed = false this.pendingOutputSeq += 1 return { - records: includeSnapshot ? [] : records, + records: includeSnapshot + ? releasedHeldBytes + ? [{ kind: 'output', data: releasedHeldBytes }] + : [] + : records, seq: this.pendingOutputSeq, overflowed, snapshot: includeSnapshot ? this.emulator.getSnapshot() : null @@ -258,6 +273,10 @@ export class Session { this.recordPendingOutput({ kind: 'clear' }) } + prepareForFinalSnapshot(): string { + return this.releaseHeldShellReadyBytes() + } + dispose(): void { if (this._disposed) { return @@ -355,6 +374,9 @@ export class Session { clearTimeout(this.shellReadyTimer) this.shellReadyTimer = null } + this.shellReadyScanState = null + this.preReadyStdinQueue = [] + this.postReadyFlushGate.clear() try { this.subprocess.dispose() } catch (err) { @@ -392,16 +414,28 @@ export class Session { return } - // Feed data to headless emulator for state tracking - this.emulator.write(data) - this.recordPendingOutput({ kind: 'output', data }) - - if (this._shellState === 'pending') { - this.scanForShellMarker(data) + if (this._shellState === 'pending' && this.shellReadyScanState) { + const scanned = scanForShellReady(this.shellReadyScanState, data) + data = scanned.output + if (scanned.matched) { + this.transitionToReady(scanned.postMarkerBytesObserved) + } } else { this.postReadyFlushGate.notifyData() } + this.emitSubprocessOutput(data) + } + + private emitSubprocessOutput(data: string): void { + if (data.length === 0) { + return + } + + // Feed data to headless emulator for state tracking + this.emulator.write(data) + this.recordPendingOutput({ kind: 'output', data }) + // Broadcast to attached clients for (const client of this.attachedClients) { client.onData(data) @@ -415,6 +449,7 @@ export class Session { this._exitCode = code this._state = 'exited' + this.releaseHeldShellReadyBytes() if (this.killTimer) { clearTimeout(this.killTimer) @@ -448,25 +483,23 @@ export class Session { this.onSessionExit?.(code) } - private scanForShellMarker(data: string): void { - this.markerBuffer += data - - const markerIdx = this.markerBuffer.indexOf(SHELL_READY_MARKER) - if (markerIdx !== -1) { - this.markerBuffer = '' - this.transitionToReady() - return - } - - // Keep only the tail that could be the start of a partial marker match - const maxPartial = SHELL_READY_MARKER.length - 1 - if (this.markerBuffer.length > maxPartial) { - this.markerBuffer = this.markerBuffer.slice(-maxPartial) + private releaseHeldShellReadyBytes(): string { + if (!this.shellReadyScanState) { + return '' } + const heldBytes = drainShellReadyHeldBytes(this.shellReadyScanState) + this.shellReadyScanState = null + // Why: daemon scanning now runs before emulator/client fan-out so marker + // bytes can be stripped. If readiness never completes, preserve the + // previous behavior by releasing any held prefix before timeout or exit + // state changes discard it. + this.emitSubprocessOutput(heldBytes) + return heldBytes } - private transitionToReady(): void { + private transitionToReady(postMarkerBytesObserved = false): void { this._shellState = 'ready' + this.shellReadyScanState = null if (this.shellReadyTimer) { clearTimeout(this.shellReadyTimer) this.shellReadyTimer = null @@ -474,7 +507,7 @@ export class Session { if (this.preReadyStdinQueue.length === 0) { return } - this.postReadyFlushGate.arm() + this.postReadyFlushGate.arm(postMarkerBytesObserved) } private onShellReadyTimeout(): void { @@ -483,6 +516,7 @@ export class Session { return } this._shellState = 'timed_out' + this.releaseHeldShellReadyBytes() this.flushPreReadyQueue() } diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index bda5f4bedb8..405b38982ed 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -169,6 +169,31 @@ describe('TerminalHost', () => { ) }) + it('uses the short daemon settle path when marker and prompt arrive together', async () => { + vi.useFakeTimers() + try { + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + command: 'echo hello', + shellReadySupported: true, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + lastSubprocess._onDataCb?.('\x1b]777;orca-shell-ready\x07\r\nuser@host $ ') + vi.advanceTimersByTime(29) + expect(lastSubprocess.write).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + expect(lastSubprocess.write).toHaveBeenCalledWith( + process.platform === 'win32' ? 'echo hello\r' : 'echo hello\n' + ) + } finally { + vi.useRealTimers() + } + }) + it('does not write startup commands already embedded in shell args', async () => { spawnFn = vi.fn(() => { const sub = createMockSubprocess({ @@ -386,6 +411,29 @@ describe('TerminalHost', () => { expect(lastSubprocess.dispose).toHaveBeenCalled() }) + it('releases held shell-ready marker prefixes before final checkpoint', async () => { + host.dispose() + const onFinalCheckpoint = vi.fn() + host = new TerminalHost({ + spawnSubprocess: spawnFn as MockSpawnFn, + onFinalCheckpoint + }) + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + shellReadySupported: true, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + lastSubprocess._onDataCb?.('\x1b]777;orca-shell-ready') + host.dispose() + + expect(onFinalCheckpoint).toHaveBeenCalledWith('session-1', expect.any(Object), [ + { kind: 'output', data: '\x1b]777;orca-shell-ready' } + ]) + }) + it('does not list exited sessions', async () => { await host.createOrAttach({ sessionId: 'session-1', diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 6dd0909dfb2..dc4d3c7c69e 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -58,7 +58,11 @@ export type TerminalHostOptions = { // Why: on graceful shutdown, the host writes final checkpoints for all live // sessions before killing them. This bypasses the RPC round-trip — the daemon // writes checkpoints in-process, guaranteeing completion before teardown. - onFinalCheckpoint?: (sessionId: string, snapshot: TerminalSnapshot) => void + onFinalCheckpoint?: ( + sessionId: string, + snapshot: TerminalSnapshot, + records: TakePendingOutputResult['records'] + ) => void // Why: production keeps a large cap, but tests need a small deterministic cap // without spawning thousands of full terminal sessions. maxTombstones?: number @@ -258,12 +262,16 @@ export class TerminalHost { // Why: same null-not-throw semantics as getSnapshot — incremental // checkpoints are best-effort against sessions that may have just exited. - takePendingOutput(sessionId: string, includeSnapshot: boolean): TakePendingOutputResult | null { + takePendingOutput( + sessionId: string, + includeSnapshot: boolean, + opts: { teardownSnapshot?: boolean } = {} + ): TakePendingOutputResult | null { const session = this.sessions.get(sessionId) if (!session || !session.isAlive) { return null } - return session.takePendingOutput(includeSnapshot) + return session.takePendingOutput(includeSnapshot, opts) } isKilled(sessionId: string): boolean { @@ -300,10 +308,10 @@ export class TerminalHost { if (!session.isAlive) { continue } - const snapshot = session.getSnapshot() - if (snapshot) { + const take = session.takePendingOutput(true, { teardownSnapshot: true }) + if (take?.snapshot) { try { - this.onFinalCheckpoint(sessionId, snapshot) + this.onFinalCheckpoint(sessionId, take.snapshot, take.records) } catch { // Best-effort — don't block shutdown } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 5251b7c82ce..10964feff44 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -7,9 +7,9 @@ import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery // when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. // Why: bump when adding daemon wire behavior so same-version old daemons do // not silently accept the handshake and then reject new RPCs. -export const PROTOCOL_VERSION = 17 +export const PROTOCOL_VERSION = 18 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17 ] as const // ─── Session State Machine ────────────────────────────────────────── @@ -246,6 +246,11 @@ export type TakePendingOutputRequest = { * snapshot taken in a separate request could include bytes that a later * take would replay again, duplicating content on cold restore. */ includeSnapshot?: boolean + /** True only for final checkpoints taken immediately before PTY teardown. + * This lets the daemon release pending parser-state bytes that should be + * preserved before the backing PTY is destroyed, without disturbing live + * full checkpoints or warm-reconnect checkpoints. */ + teardownSnapshot?: boolean } } diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 7a9c73db4ba..5e0261c601a 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -5055,11 +5055,11 @@ describe('registerPtyHandlers', () => { mockProc.emitData('\x1b]777;orca-shell-ready\x07') await Promise.resolve() - vi.advanceTimersByTime(49) + vi.advanceTimersByTime(50) await Promise.resolve() expect(mockProc.proc.write).not.toHaveBeenCalled() - vi.advanceTimersByTime(1) + vi.advanceTimersByTime(150) await Promise.resolve() expect(mockProc.proc.write).toHaveBeenCalledWith("codex 'linked issue context'\n") } finally { @@ -5067,6 +5067,38 @@ describe('registerPtyHandlers', () => { } }) + posixOnlyIt( + 'uses the short settle path for delivery-hinted Codex when prompt follows the marker', + async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp', + command: "codex 'linked issue context'", + startupCommandDelivery: 'shell-ready' + }) + + mockProc.emitData('\x1b]777;orca-shell-ready\x07\r\nuser@host % ') + await Promise.resolve() + vi.advanceTimersByTime(29) + await Promise.resolve() + expect(mockProc.proc.write).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + await Promise.resolve() + expect(mockProc.proc.write).toHaveBeenCalledWith("codex 'linked issue context'\n") + } finally { + vi.useRealTimers() + } + } + ) + posixOnlyIt('waits for shell-ready when Codex uses the native prefill flag', async () => { vi.useFakeTimers() const mockProc = createMockProc() diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index e9187f341c9..c058fa57151 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -95,9 +95,16 @@ describe('LocalPtyProvider', () => { exitCb = undefined mockProc = { - onData: vi.fn(), + onData: vi.fn(() => ({ dispose: vi.fn() })), onExit: vi.fn((cb: (info: { exitCode: number }) => void) => { exitCb = cb + return { + dispose: () => { + if (exitCb === cb) { + exitCb = undefined + } + } + } }), write: vi.fn(), resize: vi.fn(), @@ -211,15 +218,66 @@ describe('LocalPtyProvider', () => { const dataCallback = mockProc.onData.mock.calls[0]?.[0] as (data: string) => void dataCallback('\x1b]777;orca-shell-ready\x07user@host % ') await Promise.resolve() - vi.advanceTimersByTime(50) + vi.advanceTimersByTime(29) await Promise.resolve() + expect(mockProc.write).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + await Promise.resolve() expect(mockProc.write).toHaveBeenCalledWith("printf 'linked issue context'\n") } finally { vi.useRealTimers() } }) + it('releases held marker-prefix bytes when local shell readiness times out', async () => { + vi.useFakeTimers() + const onData = vi.fn() + provider.configure({ onData }) + try { + await provider.spawn({ cols: 80, rows: 24, command: 'printf ready' }) + const dataCallback = mockProc.onData.mock.calls[0]?.[0] as (data: string) => void + + dataCallback('\x1b]777;orca-shell-ready') + expect(onData).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1500) + await Promise.resolve() + + expect(onData).toHaveBeenCalledWith( + expect.any(String), + '\x1b]777;orca-shell-ready', + expect.any(Number) + ) + expect(mockProc.write).not.toHaveBeenCalled() + + vi.advanceTimersByTime(200) + await Promise.resolve() + expect(mockProc.write).toHaveBeenCalledWith('printf ready\n') + } finally { + vi.useRealTimers() + } + }) + + it('releases held marker-prefix bytes when local shell exits before readiness', async () => { + const onData = vi.fn() + provider.configure({ onData }) + + await provider.spawn({ cols: 80, rows: 24, command: 'printf ready' }) + const dataCallback = mockProc.onData.mock.calls[0]?.[0] as (data: string) => void + + dataCallback('\x1b]777;orca-shell-ready') + expect(onData).not.toHaveBeenCalled() + + exitCb?.({ exitCode: 0 }) + + expect(onData).toHaveBeenCalledWith( + expect.any(String), + '\x1b]777;orca-shell-ready', + expect.any(Number) + ) + }) + it('honors explicit terminal env overrides after deleting requested defaults', async () => { provider.configure({ buildSpawnEnv: (_id, env) => { @@ -662,6 +720,21 @@ describe('LocalPtyProvider', () => { expect(destroySpy).not.toHaveBeenCalled() }) + it('cancels pending shell-ready startup delivery on forced shutdown', async () => { + vi.useFakeTimers() + try { + const { id } = await provider.spawn({ cols: 80, rows: 24, command: 'printf ready' }) + + await provider.shutdown(id, { immediate: true }) + vi.advanceTimersByTime(2000) + await Promise.resolve() + + expect(mockProc.write).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('is a no-op for unknown PTY ids', async () => { await provider.shutdown('nonexistent', { immediate: true }) expect(mockProc.kill).not.toHaveBeenCalled() diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 3d5732fd1d9..c64a5511315 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -26,10 +26,12 @@ import { getAttributionShellLaunchConfig, getShellReadyLaunchConfig, createShellReadyScanState, + drainShellReadyHeldBytes, scanForShellReady, writeStartupCommandWhenShellReady, STARTUP_COMMAND_READY_MAX_WAIT_MS } from './local-pty-shell-ready' +import type { ShellReadySignal } from './local-pty-shell-ready' import { removeInheritedNoColor } from '../pty/terminal-color-env' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { addWslEnvKeys } from '../wsl-env' @@ -60,6 +62,7 @@ const ptyAgentForegroundContextPaths = new Map() // stale callbacks survive into node::FreeEnvironment() where NAPI attempts // to invoke/clean them up on a destroyed environment, triggering a SIGABRT. const ptyDisposables = new Map void }[]>() +const ptyCleanupCallbacks = new Map void>() let loadGeneration = 0 const ptyLoadGeneration = new Map() @@ -135,6 +138,15 @@ function disposePtyListeners(id: string): void { } } +function runPtyCleanup(id: string): void { + const cleanup = ptyCleanupCallbacks.get(id) + if (!cleanup) { + return + } + ptyCleanupCallbacks.delete(id) + cleanup() +} + /** * Resolves a WSL context from a worktree id whose path is already a WSL path. */ @@ -160,6 +172,7 @@ function getWslContextFromPreferredDistro( * Removes all local tracking state for a PTY id after teardown. */ function clearPtyState(id: string): void { + runPtyCleanup(id) disposePtyListeners(id) ptyProcesses.delete(id) ptyShellName.delete(id) @@ -249,6 +262,7 @@ function destroyPtyProcess(proc: pty.IPty, options: { alreadyKilled?: boolean } * Kills a local PTY and clears all associated local provider state. */ function safeKillAndClean(id: string, proc: pty.IPty): void { + runPtyCleanup(id) disposePtyListeners(id) try { proc.kill() @@ -618,17 +632,17 @@ export class LocalPtyProvider implements IPtyProvider { this.opts.onSpawned?.(id) // Shell-ready startup command support - let resolveShellReady: (() => void) | null = null + let resolveShellReady: ((signal: ShellReadySignal) => void) | null = null let shellReadyTimeout: ReturnType | null = null const shellReadyScanState = shellReadyLaunch?.supportsReadyMarker ? createShellReadyScanState() : null const shellReadyPromise = args.command - ? new Promise((resolve) => { + ? new Promise((resolve) => { resolveShellReady = resolve }) - : Promise.resolve() - const finishShellReady = (): void => { + : Promise.resolve({ postMarkerBytesObserved: false }) + const finishShellReady = (signal: ShellReadySignal): void => { if (!resolveShellReady) { return } @@ -638,18 +652,44 @@ export class LocalPtyProvider implements IPtyProvider { } const resolve = resolveShellReady resolveShellReady = null - resolve() + resolve(signal) + } + const releaseHeldShellReadyBytes = (): void => { + if (!shellReadyScanState) { + return + } + const heldBytes = drainShellReadyHeldBytes(shellReadyScanState) + if (heldBytes.length === 0) { + return + } + this.opts.onData?.(id, heldBytes, Date.now()) + for (const cb of dataListeners) { + cb({ id, data: heldBytes }) + } } if (args.command) { if (shellReadyLaunch?.supportsReadyMarker) { shellReadyTimeout = setTimeout(() => { - finishShellReady() + releaseHeldShellReadyBytes() + finishShellReady({ postMarkerBytesObserved: false }) }, STARTUP_COMMAND_READY_MAX_WAIT_MS) } else { - finishShellReady() + finishShellReady({ postMarkerBytesObserved: false }) } } let startupCommandCleanup: (() => void) | null = null + if (args.command) { + ptyCleanupCallbacks.set(id, () => { + if (shellReadyTimeout) { + clearTimeout(shellReadyTimeout) + shellReadyTimeout = null + } + releaseHeldShellReadyBytes() + startupCommandCleanup?.() + startupCommandCleanup = null + resolveShellReady = null + }) + } const disposables: { dispose: () => void }[] = [] const onDataDisposable = proc.onData((rawData) => { @@ -658,7 +698,7 @@ export class LocalPtyProvider implements IPtyProvider { const scanned = scanForShellReady(shellReadyScanState, rawData) data = scanned.output if (scanned.matched) { - finishShellReady() + finishShellReady({ postMarkerBytesObserved: scanned.postMarkerBytesObserved }) } } if (data.length === 0) { @@ -740,8 +780,9 @@ export class LocalPtyProvider implements IPtyProvider { // Why: disposePtyListeners removes the onExit callback, so the natural // exit cleanup path from node-pty won't fire. Cleanup and notification // must happen unconditionally after the try/catch. - // Note: clearPtyState calls disposePtyListeners internally, so we only - // need to call it once via clearPtyState after killing the process. + // Timer/writer cleanup must happen here too: disposing listeners prevents + // the natural onExit callback from running the usual clearPtyState path. + runPtyCleanup(id) disposePtyListeners(id) try { proc.kill() @@ -749,10 +790,7 @@ export class LocalPtyProvider implements IPtyProvider { /* Process may already be dead */ } destroyPtyProcess(proc, { alreadyKilled: true }) - ptyProcesses.delete(id) - ptyShellName.delete(id) - ptyAgentForegroundContextPaths.delete(id) - ptyLoadGeneration.delete(id) + clearPtyState(id) this.opts.onExit?.(id, -1) for (const cb of exitListeners) { cb({ id, code: -1 }) diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index 629baf76496..8eed31c3b93 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -97,9 +97,8 @@ describe('writeStartupCommandWhenShellReady', () => { writeStartupCommandWhenShellReady(ready, proc, 'claude', () => {}) await ready - // flush path waits for a post-ready data chunk (prompt draw) then 30ms, - // or falls back after 50ms if no data arrives. - vi.advanceTimersByTime(50) + proc._emitData('\r\nuser@host % ') + vi.advanceTimersByTime(30) await Promise.resolve() expect(proc._writes).toEqual(['claude\n']) @@ -112,7 +111,8 @@ describe('writeStartupCommandWhenShellReady', () => { writeStartupCommandWhenShellReady(ready, proc, 'claude', () => {}) await ready - vi.advanceTimersByTime(50) + proc._emitData('\r\nPS> ') + vi.advanceTimersByTime(30) await Promise.resolve() expect(proc._writes).toEqual(['claude\r']) @@ -125,11 +125,46 @@ describe('writeStartupCommandWhenShellReady', () => { writeStartupCommandWhenShellReady(ready, proc, 'claude\n', () => {}) await ready - vi.advanceTimersByTime(50) + proc._emitData('\r\nPS> ') + vi.advanceTimersByTime(30) await Promise.resolve() expect(proc._writes).toEqual(['claude\n']) }) + + it('keeps the no-prompt fallback conservative to avoid duplicate shell echo', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }) + const proc = createMockProc() + const ready = Promise.resolve() + writeStartupCommandWhenShellReady(ready, proc, 'codex', () => {}) + + await ready + vi.advanceTimersByTime(50) + await Promise.resolve() + + expect(proc._writes).toEqual([]) + + vi.advanceTimersByTime(150) + await Promise.resolve() + + expect(proc._writes).toEqual(['codex\n']) + }) + + it('uses the short settle delay when marker scan already observed post-marker bytes', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }) + const proc = createMockProc() + const ready = Promise.resolve({ postMarkerBytesObserved: true }) + writeStartupCommandWhenShellReady(ready, proc, 'codex', () => {}) + + await ready + vi.advanceTimersByTime(29) + await Promise.resolve() + expect(proc._writes).toEqual([]) + + vi.advanceTimersByTime(1) + await Promise.resolve() + expect(proc._writes).toEqual(['codex\n']) + }) }) describe('scanForShellReady', () => { @@ -138,11 +173,53 @@ describe('scanForShellReady', () => { expect(scanForShellReady(state, 'before \x1b]777;orca-shell-readyx')).toEqual({ output: 'before \x1b]777;orca-shell-readyx', - matched: false + matched: false, + postMarkerBytesObserved: false }) expect(scanForShellReady(state, ' after')).toEqual({ output: ' after', - matched: false + matched: false, + postMarkerBytesObserved: false + }) + }) + + it('reports post-marker bytes only when bytes follow the BEL terminator in the matching call', () => { + let state = createShellReadyScanState() + expect(scanForShellReady(state, 'before \x1b]777;orca-shell-ready\x07')).toEqual({ + output: 'before ', + matched: true, + postMarkerBytesObserved: false + }) + + state = createShellReadyScanState() + expect(scanForShellReady(state, 'before \x1b]777;orca-shell-ready\x07% ')).toEqual({ + output: 'before % ', + matched: true, + postMarkerBytesObserved: true + }) + + state = createShellReadyScanState() + expect(scanForShellReady(state, 'before \x1b]777;orca-shell-ready')).toEqual({ + output: 'before ', + matched: false, + postMarkerBytesObserved: false + }) + expect(scanForShellReady(state, '\x07')).toEqual({ + output: '', + matched: true, + postMarkerBytesObserved: false + }) + + state = createShellReadyScanState() + expect(scanForShellReady(state, '\x1b]777;orca-shell-ready')).toEqual({ + output: '', + matched: false, + postMarkerBytesObserved: false + }) + expect(scanForShellReady(state, '\x07% ')).toEqual({ + output: '% ', + matched: true, + postMarkerBytesObserved: true }) }) }) diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 1175ddaf412..d8bbf94f69f 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -27,66 +27,23 @@ import { getZshShellReadyMarkerRegistrationBlock, getZshStartupFileSourceBlock } from '../shell-templates' +export { + createShellReadyScanState, + drainShellReadyHeldBytes, + scanForShellReady, + SHELL_READY_MARKER_PREFIX +} from '../shell-ready-marker-scanner' +export type { ShellReadyScanResult, ShellReadyScanState } from '../shell-ready-marker-scanner' let didEnsureShellReadyWrappers = false const STARTUP_COMMAND_READY_MAX_WAIT_MS = 1500 -const SHELL_READY_MARKER = '\x1b]777;orca-shell-ready' +const POST_SHELL_READY_STARTUP_COMMAND_DELAY_MS = 30 +const POST_SHELL_READY_STARTUP_COMMAND_FALLBACK_MS = 200 const SHELL_READY_MARKER_ESCAPED = '\\033]777;orca-shell-ready\\007' -// ── OSC 777 shell-ready scanner ───────────────────────────────────── - -export type ShellReadyScanState = { - matchPos: number - heldBytes: string -} - -export function createShellReadyScanState(): ShellReadyScanState { - return { matchPos: 0, heldBytes: '' } -} - -export function scanForShellReady( - state: ShellReadyScanState, - data: string -): { output: string; matched: boolean } { - let output = '' - - for (let i = 0; i < data.length; i += 1) { - const ch = data[i] as string - if (state.matchPos < SHELL_READY_MARKER.length) { - if (ch === SHELL_READY_MARKER[state.matchPos]) { - state.heldBytes += ch - state.matchPos += 1 - } else { - output += state.heldBytes - state.heldBytes = '' - state.matchPos = 0 - if (ch === SHELL_READY_MARKER[0]) { - state.heldBytes = ch - state.matchPos = 1 - } else { - output += ch - } - } - } else if (ch === '\x07') { - const remaining = data.slice(i + 1) - state.heldBytes = '' - state.matchPos = 0 - return { output: output + remaining, matched: true } - } else { - output += state.heldBytes - state.heldBytes = '' - state.matchPos = 0 - if (ch === SHELL_READY_MARKER[0]) { - state.heldBytes = ch - state.matchPos = 1 - } else { - output += ch - } - } - } - - return { output, matched: false } +export type ShellReadySignal = { + postMarkerBytesObserved: boolean } // ── Shell wrapper files ───────────────────────────────────────────── @@ -469,7 +426,7 @@ export function getAttributionShellLaunchConfig(shellPath: string): ShellReadyLa // ── Startup command writer ────────────────────────────────────────── export function writeStartupCommandWhenShellReady( - readyPromise: Promise, + readyPromise: Promise, proc: pty.IPty, startupCommand: string, onExit: (cleanup: () => void) => void @@ -518,7 +475,11 @@ export function writeStartupCommandWhenShellReady( proc.write(payload) } - readyPromise.then(() => { + const schedulePostReadyFlush = (): void => { + postReadyTimer = setTimeout(flush, POST_SHELL_READY_STARTUP_COMMAND_DELAY_MS) + } + + readyPromise.then((signal) => { if (sent) { return } @@ -528,26 +489,28 @@ export function writeStartupCommandWhenShellReady( // causes the characters to be echoed once by the kernel and then redisplayed // by the line editor after the prompt — producing a visible duplicate. // - // Strategy: wait for the next PTY data event after the ready marker. That - // data is the shell drawing its prompt, which means the shell is about to - // (or has already) switched to raw mode. A brief follow-up delay covers the - // gap between the last prompt write() and the tcsetattr() that enables raw - // mode. The 50ms fallback timeout handles the case where the prompt data - // arrived in the same chunk as the ready marker (no subsequent onData). + // Strategy: if the marker-completing scan already observed post-marker + // bytes, use the short settle delay directly. Otherwise, wait for the next + // PTY data event after the ready marker, with a conservative fallback for + // ambiguous marker-only or markerless cases. + if (signal?.postMarkerBytesObserved === true) { + schedulePostReadyFlush() + return + } postReadyDataDisposable = proc.onData(() => { postReadyDataDisposable?.dispose() postReadyDataDisposable = null if (postReadyTimer !== null) { clearTimeout(postReadyTimer) } - postReadyTimer = setTimeout(flush, 30) + schedulePostReadyFlush() }) postReadyTimer = setTimeout(() => { postReadyDataDisposable?.dispose() postReadyDataDisposable = null postReadyTimer = null flush() - }, 50) + }, POST_SHELL_READY_STARTUP_COMMAND_FALLBACK_MS) }) onExit(cleanup) } diff --git a/src/main/shell-ready-marker-scanner.ts b/src/main/shell-ready-marker-scanner.ts new file mode 100644 index 00000000000..d0633fa1393 --- /dev/null +++ b/src/main/shell-ready-marker-scanner.ts @@ -0,0 +1,69 @@ +export const SHELL_READY_MARKER_PREFIX = '\x1b]777;orca-shell-ready' +export const SHELL_READY_MARKER = `${SHELL_READY_MARKER_PREFIX}\x07` + +export type ShellReadyScanState = { + matchPos: number + heldBytes: string +} + +export type ShellReadyScanResult = { + output: string + matched: boolean + postMarkerBytesObserved: boolean +} + +export function createShellReadyScanState(): ShellReadyScanState { + return { matchPos: 0, heldBytes: '' } +} + +export function drainShellReadyHeldBytes(state: ShellReadyScanState): string { + const heldBytes = state.heldBytes + state.heldBytes = '' + state.matchPos = 0 + return heldBytes +} + +export function scanForShellReady(state: ShellReadyScanState, data: string): ShellReadyScanResult { + let output = '' + + for (let i = 0; i < data.length; i += 1) { + const ch = data[i] as string + if (state.matchPos < SHELL_READY_MARKER_PREFIX.length) { + if (ch === SHELL_READY_MARKER_PREFIX[state.matchPos]) { + state.heldBytes += ch + state.matchPos += 1 + } else { + output += state.heldBytes + state.heldBytes = '' + state.matchPos = 0 + if (ch === SHELL_READY_MARKER_PREFIX[0]) { + state.heldBytes = ch + state.matchPos = 1 + } else { + output += ch + } + } + } else if (ch === '\x07') { + const remaining = data.slice(i + 1) + state.heldBytes = '' + state.matchPos = 0 + return { + output: output + remaining, + matched: true, + postMarkerBytesObserved: remaining.length > 0 + } + } else { + output += state.heldBytes + state.heldBytes = '' + state.matchPos = 0 + if (ch === SHELL_READY_MARKER_PREFIX[0]) { + state.heldBytes = ch + state.matchPos = 1 + } else { + output += ch + } + } + } + + return { output, matched: false, postMarkerBytesObserved: false } +} From d9b2d89406d8ebe8e084cccbaf3dfe3b22d6c64e Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:57:33 -0700 Subject: [PATCH 004/282] Fix Windows NeoVim open-in launcher (#6026) --- src/main/external-editor-launch.test.ts | 47 +++++++++++++++++++ src/main/external-editor-launch.ts | 62 +++++++++++++++++++++++-- src/main/ipc/shell.test.ts | 26 +++++++++++ src/main/ipc/shell.ts | 4 +- 4 files changed, 135 insertions(+), 4 deletions(-) diff --git a/src/main/external-editor-launch.test.ts b/src/main/external-editor-launch.test.ts index fc33d182c3a..b9d4b20113a 100644 --- a/src/main/external-editor-launch.test.ts +++ b/src/main/external-editor-launch.test.ts @@ -9,6 +9,7 @@ describe('resolveExternalEditorLaunchSpec', () => { }) expect(spec).toEqual({ kind: 'executable', + hideWindowsConsole: true, spawnCmd: expect.any(String), spawnArgs: ['--new-window', '/tmp/workspace'] }) @@ -21,6 +22,7 @@ describe('resolveExternalEditorLaunchSpec', () => { }) ).toEqual({ kind: 'shell', + hideWindowsConsole: true, spawnCmd: '/bin/sh', spawnArgs: ['-c', "open -a \"Typora\" '/tmp/note'\\''s.md'"] }) @@ -31,6 +33,7 @@ describe('resolveExternalEditorLaunchSpec', () => { resolveExternalEditorLaunchSpec('start "" notepad', 'C:\\note.md', { platform: 'win32' }) ).toEqual({ kind: 'shell', + hideWindowsConsole: true, spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/s', '/c', 'start "" notepad C:\\note.md'] }) @@ -41,8 +44,52 @@ describe('resolveExternalEditorLaunchSpec', () => { resolveExternalEditorLaunchSpec('start "" notepad', 'C:\\my notes.md', { platform: 'win32' }) ).toEqual({ kind: 'shell', + hideWindowsConsole: true, spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/s', '/c', 'start "" notepad "C:\\my notes.md"'] }) }) + + it('treats unquoted Windows executable paths with spaces as executable launchers', () => { + expect( + resolveExternalEditorLaunchSpec( + 'C:\\Program Files\\Neovim\\bin\\nvim.exe', + 'C:\\workspaces\\orca', + { platform: 'win32' } + ) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: false, + spawnCmd: 'C:\\Program Files\\Neovim\\bin\\nvim.exe', + spawnArgs: ['C:\\workspaces\\orca'] + }) + }) + + it('treats quoted Windows executable paths with spaces as executable launchers', () => { + expect( + resolveExternalEditorLaunchSpec( + '"C:\\Program Files\\Neovim\\bin\\nvim.exe"', + 'C:\\workspaces\\orca', + { platform: 'win32' } + ) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: false, + spawnCmd: 'C:\\Program Files\\Neovim\\bin\\nvim.exe', + spawnArgs: ['C:\\workspaces\\orca'] + }) + }) + + it('shows the Windows console for NeoVim shell commands with arguments', () => { + expect( + resolveExternalEditorLaunchSpec('nvim --clean', 'C:\\workspaces\\orca', { + platform: 'win32' + }) + ).toEqual({ + kind: 'shell', + hideWindowsConsole: false, + spawnCmd: getCmdExePath(), + spawnArgs: ['/d', '/s', '/c', 'nvim --clean C:\\workspaces\\orca'] + }) + }) }) diff --git a/src/main/external-editor-launch.ts b/src/main/external-editor-launch.ts index 6d4f51695a4..b6c9e977921 100644 --- a/src/main/external-editor-launch.ts +++ b/src/main/external-editor-launch.ts @@ -1,17 +1,20 @@ -import { basename, win32 } from 'node:path' +import { basename, posix, win32 } from 'node:path' import { resolveCliCommand } from './codex-cli/command' import { getCmdExePath } from './win32-utils' export const EXTERNAL_EDITOR_CLI_COMMAND = 'code' +const WINDOWS_CONSOLE_EDITORS = new Set(['nvim', 'vim']) export type ExternalEditorLaunchSpec = | { kind: 'executable' + hideWindowsConsole: boolean spawnCmd: string spawnArgs: string[] } | { kind: 'shell' + hideWindowsConsole: boolean spawnCmd: string spawnArgs: string[] } @@ -33,11 +36,51 @@ function escapePathForShell(pathValue: string, platform: NodeJS.Platform): strin : escapePosixPathForShell(pathValue) } -function getLauncherBaseName(command: string): string { - const name = command.includes('\\') ? win32.basename(command) : basename(command) +function getLauncherBaseName(command: string, options: { shellCommand?: boolean } = {}): string { + const normalized = options.shellCommand + ? getLeadingShellCommandToken(command) + : stripMatchingQuotes(command) + const name = normalized.includes('\\') ? win32.basename(normalized) : basename(normalized) return name.replace(/\.(?:cmd|exe|bat)$/i, '').toLowerCase() } +function getLeadingShellCommandToken(command: string): string { + const trimmed = command.trim() + const quote = trimmed[0] + if (quote === '"' || quote === "'") { + const closingIndex = trimmed.indexOf(quote, 1) + if (closingIndex > 0) { + return trimmed.slice(1, closingIndex) + } + } + return trimmed.split(/\s+/, 1)[0] ?? '' +} + +function stripMatchingQuotes(value: string): string { + const trimmed = value.trim() + const quote = trimmed[0] + if ((quote === '"' || quote === "'") && trimmed.endsWith(quote)) { + return trimmed.slice(1, -1) + } + return trimmed +} + +function isDirectExecutablePath(command: string, platform: NodeJS.Platform): boolean { + const unquoted = stripMatchingQuotes(command) + if (!/[\\/]/.test(unquoted)) { + return false + } + return platform === 'win32' ? win32.isAbsolute(unquoted) : posix.isAbsolute(unquoted) +} + +function shouldShowWindowsConsole( + command: string, + platform: NodeJS.Platform, + options: { shellCommand?: boolean } = {} +): boolean { + return platform === 'win32' && WINDOWS_CONSOLE_EDITORS.has(getLauncherBaseName(command, options)) +} + function buildExecutableArgs(editorCommand: string, pathValue: string): string[] { if (getLauncherBaseName(editorCommand) === 'cursor') { // Why: Cursor can route bare folder launches through the last active @@ -60,12 +103,14 @@ function buildShellLaunchSpec( if (platform === 'win32') { return { kind: 'shell', + hideWindowsConsole: !shouldShowWindowsConsole(command, platform, { shellCommand: true }), spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/s', '/c', shellCommand] } } return { kind: 'shell', + hideWindowsConsole: true, spawnCmd: '/bin/sh', spawnArgs: ['-c', shellCommand] } @@ -79,6 +124,16 @@ export function resolveExternalEditorLaunchSpec( const platform = options.platform ?? process.platform const trimmed = command?.trim() || EXTERNAL_EDITOR_CLI_COMMAND + if (isDirectExecutablePath(trimmed, platform)) { + const editorCommand = stripMatchingQuotes(trimmed) + return { + kind: 'executable', + hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform), + spawnCmd: editorCommand, + spawnArgs: buildExecutableArgs(editorCommand, pathValue) + } + } + if (isCompoundShellCommand(trimmed)) { return buildShellLaunchSpec(trimmed, pathValue, platform) } @@ -86,6 +141,7 @@ export function resolveExternalEditorLaunchSpec( const editorCommand = resolveCliCommand(trimmed, { platform }) return { kind: 'executable', + hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform), spawnCmd: editorCommand, spawnArgs: buildExecutableArgs(editorCommand, pathValue) } diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts index 09e98c8e975..ad7b03e06c8 100644 --- a/src/main/ipc/shell.test.ts +++ b/src/main/ipc/shell.test.ts @@ -52,6 +52,7 @@ vi.mock('../codex-cli/command', () => ({ })) vi.mock('../win32-utils', () => ({ + getCmdExePath: () => 'C:\\Windows\\System32\\cmd.exe', getSpawnArgsForWindows: getSpawnArgsForWindowsMock })) @@ -305,6 +306,31 @@ describe('registerShellHandlers', () => { ]) }) + it('shows the Windows console for NeoVim executable launchers on Windows', async () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const workspacePath = resolve('workspace') + const handler = getHandler('shell:openInExternalEditor') + const nvimPath = 'C:\\Program Files\\Neovim\\bin\\nvim.exe' + + try { + await expect(handler({}, workspacePath, nvimPath)).resolves.toEqual({ ok: true }) + expect(resolveCliCommandMock).not.toHaveBeenCalled() + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(nvimPath, [ + normalize(workspacePath) + ]) + expect(spawnMock).toHaveBeenCalledWith(nvimPath, [normalize(workspacePath)], { + detached: true, + stdio: 'ignore', + windowsHide: false + }) + } finally { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor) + } + } + }) + it('forces Cursor launcher folders into a new window', async () => { resolveCliCommandMock.mockReturnValueOnce('/usr/local/bin/cursor') const workspacePath = resolve('workspace') diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index a2eac833ff4..85cccbc7c16 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -65,7 +65,9 @@ async function launchExternalEditor(pathValue: string, command?: string): Promis const child = spawn(spawnCmd, spawnArgs, { detached: true, stdio: 'ignore', - windowsHide: true + // Why: terminal editors such as nvim need a visible console on Windows; + // GUI editor launches stay hidden to avoid command-shim flashes. + windowsHide: launchSpec.hideWindowsConsole }) let settled = false From a8feafe92ac79ba23fab6d64d1befbe400d92d15 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:41:48 -0700 Subject: [PATCH 005/282] Fix mobile file tab close sync Close mobile session file tabs through the underlying editor file so desktop stops republishing closed docs/files. --- src/renderer/src/hooks/useIpcEvents.ts | 6 +- .../src/runtime/mobile-session-tab-close.ts | 29 ++++ src/renderer/src/store/slices/tabs.test.ts | 131 +++++++++++++++++- 3 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/runtime/mobile-session-tab-close.ts diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 9111bb5be97..34f96e34f93 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -78,6 +78,7 @@ import { releaseBrowserAutomationVisibility } from '@/components/browser-pane/browser-automation-visibility' import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' +import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close' import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue' import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events' import { createRuntimeClientEventsSync } from './runtime-client-events-sync' @@ -1606,7 +1607,10 @@ export function useIpcEvents(): void { guardPinnedTabClose({ isPinned: isPinnedSessionTab(store, worktreeId, tabId), tabLabel: resolvePinnedTabLabel(store, worktreeId, tabId), - onClose: () => useAppStore.getState().closeUnifiedTab(tabId) + onClose: () => { + const currentStore = useAppStore.getState() + closeMobileSessionTabInStore(currentStore, worktreeId, tabId) + } }) }) ) diff --git a/src/renderer/src/runtime/mobile-session-tab-close.ts b/src/renderer/src/runtime/mobile-session-tab-close.ts new file mode 100644 index 00000000000..3d97fe02c4b --- /dev/null +++ b/src/renderer/src/runtime/mobile-session-tab-close.ts @@ -0,0 +1,29 @@ +import type { AppState } from '../store/types' + +const EDITOR_SESSION_CONTENT_TYPES = new Set(['editor', 'diff', 'conflict-review', 'check-details']) + +export function closeMobileSessionTabInStore( + store: Pick, + worktreeId: string, + tabId: string +): boolean { + const unifiedTab = (store.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.id === tabId || tab.entityId === tabId + ) + if (unifiedTab && EDITOR_SESSION_CONTENT_TYPES.has(unifiedTab.contentType)) { + store.closeFile(unifiedTab.entityId) + return true + } + + const fallbackFile = store.openFiles.find( + (file) => file.worktreeId === worktreeId && file.id === tabId + ) + if (fallbackFile) { + // Why: mobile may receive fallback file-id tabs from openFiles after the + // unified tab wrapper has already closed; close the source file too. + store.closeFile(fallbackFile.id) + return true + } + + return store.closeUnifiedTab(tabId) !== null +} diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index f7823486df9..792c722f558 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -3,6 +3,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { Tab, TabGroup } from '../../../../shared/types' import type * as AgentStatusModule from '@/lib/agent-status' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultUIState } from '../../../../shared/constants' +import { buildMobileSessionTabSnapshots } from '../../runtime/sync-runtime-graph' +import { closeMobileSessionTabInStore } from '../../runtime/mobile-session-tab-close' // Mock sonner (imported by repos.ts) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) @@ -102,7 +104,7 @@ const mockApi = { // @ts-expect-error -- mock globalThis.window = { api: mockApi } -import { createTestStore } from './store-test-helpers' +import { createTestStore, makeOpenFile, makeTabGroup, makeUnifiedTab } from './store-test-helpers' const WT = 'repo1::/tmp/feature' @@ -295,6 +297,133 @@ describe('TabsSlice', () => { expect(result).toBeNull() }) + it('removes a mobile-closed markdown tab from open files so it is not republished', () => { + const groupId = 'editor-group' + const file = makeOpenFile({ + id: '/tmp/feature/README.md', + filePath: '/tmp/feature/README.md', + relativePath: 'README.md', + language: 'markdown', + worktreeId: WT + }) + const tab = makeUnifiedTab({ + id: 'readme-unified', + entityId: file.id, + contentType: 'editor', + label: 'README.md', + worktreeId: WT, + groupId + }) + store.setState({ + openFiles: [file], + unifiedTabsByWorktree: { [WT]: [tab] }, + groupsByWorktree: { + [WT]: [ + makeTabGroup({ + id: groupId, + worktreeId: WT, + activeTabId: tab.id, + tabOrder: [tab.id], + recentTabIds: [tab.id] + }) + ] + }, + activeGroupIdByWorktree: { [WT]: groupId }, + activeFileId: file.id, + activeFileIdByWorktree: { [WT]: file.id }, + activeWorktreeId: WT, + activeTabType: 'editor', + activeTabTypeByWorktree: { [WT]: 'editor' } + }) + + expect(buildMobileSessionTabSnapshots(store.getState())[0]?.tabs).toMatchObject([ + { id: tab.id, type: 'markdown', filePath: file.filePath } + ]) + + expect(closeMobileSessionTabInStore(store.getState(), WT, tab.id)).toBe(true) + + expect(store.getState().openFiles).toEqual([]) + expect(buildMobileSessionTabSnapshots(store.getState())[0]?.tabs ?? []).toEqual([]) + }) + + it('removes a mobile-closed regular file tab from open files so fallback closes do not resurrect', () => { + const groupId = 'editor-group' + const file = makeOpenFile({ + id: '/tmp/feature/src/app.ts', + filePath: '/tmp/feature/src/app.ts', + relativePath: 'src/app.ts', + language: 'typescript', + worktreeId: WT + }) + const tab = makeUnifiedTab({ + id: 'app-unified', + entityId: file.id, + contentType: 'editor', + label: 'app.ts', + worktreeId: WT, + groupId + }) + store.setState({ + openFiles: [file], + unifiedTabsByWorktree: { [WT]: [tab] }, + groupsByWorktree: { + [WT]: [ + makeTabGroup({ + id: groupId, + worktreeId: WT, + activeTabId: tab.id, + tabOrder: [tab.id], + recentTabIds: [tab.id] + }) + ] + }, + activeGroupIdByWorktree: { [WT]: groupId }, + activeFileId: file.id, + activeFileIdByWorktree: { [WT]: file.id }, + activeWorktreeId: WT, + activeTabType: 'editor', + activeTabTypeByWorktree: { [WT]: 'editor' } + }) + + expect(buildMobileSessionTabSnapshots(store.getState())[0]?.tabs).toMatchObject([ + { id: tab.id, type: 'file', filePath: file.filePath } + ]) + + expect(closeMobileSessionTabInStore(store.getState(), WT, tab.id)).toBe(true) + + expect(store.getState().openFiles).toEqual([]) + expect(buildMobileSessionTabSnapshots(store.getState())[0]?.tabs ?? []).toEqual([]) + }) + + it('closes a mobile fallback file-id tab after the unified wrapper is already gone', () => { + const file = makeOpenFile({ + id: '/tmp/feature/src/app.ts', + filePath: '/tmp/feature/src/app.ts', + relativePath: 'src/app.ts', + language: 'typescript', + worktreeId: WT + }) + store.setState({ + openFiles: [file], + unifiedTabsByWorktree: { [WT]: [] }, + groupsByWorktree: { [WT]: [] }, + activeFileId: file.id, + activeFileIdByWorktree: { [WT]: file.id }, + activeWorktreeId: WT, + activeTabType: 'editor', + activeTabTypeByWorktree: { [WT]: 'editor' } + }) + + expect(buildMobileSessionTabSnapshots(store.getState())[0]?.tabs).toMatchObject([ + { id: file.id, type: 'file', filePath: file.filePath } + ]) + + expect(closeMobileSessionTabInStore(store.getState(), WT, file.id)).toBe(true) + + expect(store.getState().openFiles).toEqual([]) + expect(buildMobileSessionTabSnapshots(store.getState())[0]?.tabs ?? []).toEqual([]) + }) + it('activates the previously-active tab (MRU) instead of the visual neighbor', () => { const t1 = store.getState().createUnifiedTab(WT, 'terminal') const t2 = store.getState().createUnifiedTab(WT, 'terminal') From 6c93d311cf430a56f2cbe8ed8c16ee4be2216763 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:22:22 -0700 Subject: [PATCH 006/282] Keep workspace board above the status bar (#6035) Co-authored-by: Orca --- .../src/components/sidebar/Sidebar.test.tsx | 28 +++++++++-- ...paceKanbanDrawer.task-status-sync.test.tsx | 48 +++++++++++++++++-- .../sidebar/WorkspaceKanbanDrawer.tsx | 20 ++++++-- src/renderer/src/components/sidebar/index.tsx | 2 + 4 files changed, 88 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/sidebar/Sidebar.test.tsx b/src/renderer/src/components/sidebar/Sidebar.test.tsx index c97f3514b21..cb47521f571 100644 --- a/src/renderer/src/components/sidebar/Sidebar.test.tsx +++ b/src/renderer/src/components/sidebar/Sidebar.test.tsx @@ -45,8 +45,18 @@ vi.mock('./SidebarToolbar', () => ({ })) vi.mock('./WorkspaceKanbanDrawer', () => ({ - default: ({ leftSidebarStyle }: { leftSidebarStyle?: CSSProperties }) => ( -
+ default: ({ + leftSidebarStyle, + statusBarVisible + }: { + leftSidebarStyle?: CSSProperties + statusBarVisible: boolean + }) => ( +
) })) @@ -76,7 +86,7 @@ vi.mock('./useWorkspaceBoardPanel', () => ({ import Sidebar from './index' -function setSidebarState(settings: GlobalSettings): void { +function setSidebarState(settings: GlobalSettings, statusBarVisible = true): void { mocks.state = { activeModal: null, fetchAllWorktrees: vi.fn(), @@ -84,7 +94,8 @@ function setSidebarState(settings: GlobalSettings): void { setSidebarWidth: vi.fn(), settings, sidebarOpen: true, - sidebarWidth: 320 + sidebarWidth: 320, + statusBarVisible } } @@ -112,4 +123,13 @@ describe('Sidebar', () => { expect(markup).toContain('data-testid="workspace-kanban-drawer"') expect(markup.match(/--worktree-sidebar:#101820/g)).toHaveLength(2) }) + + it('passes status bar visibility into the workspace board drawer', () => { + setSidebarState(getDefaultSettings('/tmp'), false) + + const markup = renderSidebar() + + expect(markup).toContain('data-testid="workspace-kanban-drawer"') + expect(markup).toContain('data-status-bar-visible="false"') + }) }) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.task-status-sync.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.task-status-sync.test.tsx index d0862b78e32..a5126be1585 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.task-status-sync.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.task-status-sync.test.tsx @@ -58,8 +58,21 @@ vi.mock('sonner', () => ({ vi.mock('@/components/ui/sheet', () => ({ Sheet: ({ children }: { children: React.ReactNode }) =>
{children}
, - SheetContent: ({ children }: { children: React.ReactNode }) => ( -
{children}
+ SheetContent: ({ + children, + overlayStyle, + style + }: { + children: React.ReactNode + overlayStyle?: React.CSSProperties + style?: React.CSSProperties + }) => ( + <> +
+
+ {children} +
+ ) })) @@ -201,7 +214,7 @@ function repo(overrides: Partial = {}): Repo { } as Repo } -function renderDrawer(item: Worktree, enabled = true): void { +function renderDrawer(item: Worktree, enabled = true, statusBarVisible = true): void { const updateWorktreeMeta = vi.fn() const updateWorktreesMeta = vi.fn() const recordFeatureInteraction = vi.fn() @@ -226,6 +239,7 @@ function renderDrawer(item: Worktree, enabled = true): void { root.render( { }) describe('WorkspaceKanbanDrawer task status sync wiring', () => { + it('reserves the status bar row in the board sheet and overlay when visible', () => { + renderDrawer(worktree(), true, true) + + const sheet = document.querySelector('[data-slot="sheet-content"]') + const overlay = document.querySelector('[data-slot="sheet-overlay"]') + + expect(sheet?.style.top).toBe('36px') + expect(sheet?.style.bottom).toBe('24px') + expect(sheet?.style.height).toBe('auto') + expect(overlay?.style.top).toBe('36px') + expect(overlay?.style.bottom).toBe('24px') + expect(overlay?.style.pointerEvents).toBe('none') + }) + + it('keeps the board sheet and overlay flush to the viewport bottom when status bar is hidden', () => { + renderDrawer(worktree(), true, false) + + const sheet = document.querySelector('[data-slot="sheet-content"]') + const overlay = document.querySelector('[data-slot="sheet-overlay"]') + + expect(sheet?.style.top).toBe('36px') + expect(sheet?.style.bottom).toBe('0px') + expect(sheet?.style.height).toBe('auto') + expect(overlay?.style.top).toBe('36px') + expect(overlay?.style.bottom).toBe('0px') + expect(overlay?.style.pointerEvents).toBe('none') + }) + it('syncs Linear after a document-drop status move when the setting is enabled', () => { const item = worktree() renderDrawer(item) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx index 7aa81b98091..9b73e8e79b9 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx @@ -46,12 +46,16 @@ import { translate } from '@/i18n/i18n' type WorkspaceKanbanDrawerProps = { leftSidebarStyle?: React.CSSProperties open: boolean + statusBarVisible: boolean dragPreview: boolean preserveOpenForMenu: boolean onOpenChange: (open: boolean) => void onMenuOpenChange: (open: boolean) => void } +const WORKSPACE_TOP_CHROME_HEIGHT = 36 +const STATUS_BAR_RESERVE_HEIGHT = 24 + function formatTaskStatusSyncMessage(message: WorkspaceBoardTaskStatusSyncMessage): string { switch (message.kind) { case 'issue-read-failed': @@ -129,6 +133,7 @@ function formatTaskStatusSyncDescription(result: WorkspaceBoardTaskStatusSyncRes export default function WorkspaceKanbanDrawer({ leftSidebarStyle, open, + statusBarVisible, dragPreview, preserveOpenForMenu, onOpenChange, @@ -632,6 +637,9 @@ export default function WorkspaceKanbanDrawer({ const drawerLeftCss = sidebarOpen ? `var(--workspace-sidebar-live-width, ${sidebarWidth}px)` : '0px' + // Why: App reserves a bottom status row while visible; the portalled board + // must share that viewport bound instead of covering the status controls. + const drawerBottom = `${statusBarVisible ? STATUS_BAR_RESERVE_HEIGHT : 0}px` return ( @@ -639,15 +647,21 @@ export default function WorkspaceKanbanDrawer({ side="left" showCloseButton={false} className="workspace-kanban-sheet-content bg-worktree-sidebar p-0 sm:max-w-none" - overlayStyle={{ top: 36, left: drawerLeftCss, pointerEvents: 'none' }} + overlayStyle={{ + top: WORKSPACE_TOP_CHROME_HEIGHT, + bottom: drawerBottom, + left: drawerLeftCss, + pointerEvents: 'none' + }} style={ { ...leftSidebarStyle, // Why: the board is a companion to the workspace sidebar, so it // expands from the sidebar edge instead of covering the sidebar. left: drawerLeftCss, - top: 36, - height: 'calc(100% - 36px)', + top: WORKSPACE_TOP_CHROME_HEIGHT, + bottom: drawerBottom, + height: 'auto', width: `min(calc(100vw - ${drawerLeftCss}), 1294px)` } as React.CSSProperties } diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index a980f9cf8f8..00001b9d1a6 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -44,6 +44,7 @@ function Sidebar({ const settings = useAppStore((s) => s.settings) const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees) const activeModal = useAppStore((s) => s.activeModal) + const statusBarVisible = useAppStore((s) => s.statusBarVisible) const systemPrefersDark = useSystemPrefersDark() const leftSidebarStyle = useMemo( () => resolveLeftSidebarStyleVariables(settings, systemPrefersDark), @@ -201,6 +202,7 @@ function Sidebar({ Date: Mon, 22 Jun 2026 12:23:36 +0800 Subject: [PATCH 007/282] fix(i18n): correct Chinese MT errors and translate Settings panel strings (#5915) Co-authored-by: brennanb2025 --- config/scripts/locale-key-overrides.mjs | 6 +- src/renderer/src/i18n/locales/en.json | 7 +- src/renderer/src/i18n/locales/es.json | 7 +- src/renderer/src/i18n/locales/ja.json | 7 +- src/renderer/src/i18n/locales/ko.json | 7 +- src/renderer/src/i18n/locales/zh.json | 697 ++++++++++++------------ 6 files changed, 378 insertions(+), 353 deletions(-) diff --git a/config/scripts/locale-key-overrides.mjs b/config/scripts/locale-key-overrides.mjs index 787c9143ddf..d0577e80bab 100644 --- a/config/scripts/locale-key-overrides.mjs +++ b/config/scripts/locale-key-overrides.mjs @@ -341,7 +341,7 @@ const BASE_LOCALE_KEY_OVERRIDES = { 'auto.App.62ca9895a7': { ko: '스페이스', zh: '空间', ja: 'スペース' }, 'settings.appearance.statusBar.kimiToggleDescription': { ko: '활성 워크스페이스의 Kimi 구독 사용량을 표시합니다.', - zh: 'Kimi 订阅', + zh: '显示当前工作区的 Kimi 订阅使用情况。', ja: 'Kimi サブスクリプション' }, 'auto.components.mobile.MobileHero.cd4e5e816f': { @@ -541,12 +541,12 @@ const BASE_LOCALE_KEY_OVERRIDES = { }, 'auto.components.editor.EditorContent.e4b074749d': { ko: '머리말', - zh: '前线事项', + zh: '前言', ja: 'フロントマター' }, 'auto.components.editor.MarkdownPreview.2b2b31382c': { ko: '머리말', - zh: '前线事项', + zh: '前言', ja: 'フロントマター' }, 'auto.components.settings.IntegrationsPane.c0c8575e05': { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index aa5555ffafc..05d8a111b76 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -222,7 +222,12 @@ "0216895fb5": "Failed to delete branch", "19db0085fb": "Local branch deleted", "2b0afc7f14": "Could not keep local {{value0}} up to date", - "670864ab52": "Keeping local {{value0}} up to date" + "670864ab52": "Keeping local {{value0}} up to date", + "5366d13eec": "Workspace deleted, branch kept", + "2e17f825d4": "Worktree deleted, branch kept", + "78e08cd877": "Git could not safely delete branch \"{{value0}}\", so Orca kept it to avoid losing local commits.", + "3b57982bf6": "Git could not safely delete branch \"{{value0}}\" after deleting workspace \"{{value1}}\", so Orca kept it to avoid losing local commits.", + "81f13f48d2": "Git could not safely delete branch \"{{value0}}\" after deleting worktree \"{{value1}}\", so Orca kept it to avoid losing local commits." }, "repos": { "b7e14472ae": "Failed to add folder", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2fc3fbf13b1..79012408eea 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -222,7 +222,12 @@ "0216895fb5": "No se pudo eliminar la rama", "19db0085fb": "Sucursal local eliminada", "2b0afc7f14": "No se pudo mantener actualizado el {{value0}} local", - "670864ab52": "Mantener actualizado el {{value0}} local" + "670864ab52": "Mantener actualizado el {{value0}} local", + "5366d13eec": "Workspace deleted, branch kept", + "2e17f825d4": "Worktree deleted, branch kept", + "78e08cd877": "Git could not safely delete branch \"{{value0}}\", so Orca kept it to avoid losing local commits.", + "3b57982bf6": "Git could not safely delete branch \"{{value0}}\" after deleting workspace \"{{value1}}\", so Orca kept it to avoid losing local commits.", + "81f13f48d2": "Git could not safely delete branch \"{{value0}}\" after deleting worktree \"{{value1}}\", so Orca kept it to avoid losing local commits." }, "repos": { "b7e14472ae": "No se pudo agregar la carpeta", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 90e5a7a2fbd..ff0909bf734 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -222,7 +222,12 @@ "0216895fb5": "ブランチの削除に失敗しました", "19db0085fb": "ローカルブランチが削除されました", "2b0afc7f14": "ローカル {{value0}} を最新の状態に維持できませんでした", - "670864ab52": "ローカル {{value0}} を最新の状態に保つ" + "670864ab52": "ローカル {{value0}} を最新の状態に保つ", + "5366d13eec": "Workspace deleted, branch kept", + "2e17f825d4": "Worktree deleted, branch kept", + "78e08cd877": "Git could not safely delete branch \"{{value0}}\", so Orca kept it to avoid losing local commits.", + "3b57982bf6": "Git could not safely delete branch \"{{value0}}\" after deleting workspace \"{{value1}}\", so Orca kept it to avoid losing local commits.", + "81f13f48d2": "Git could not safely delete branch \"{{value0}}\" after deleting worktree \"{{value1}}\", so Orca kept it to avoid losing local commits." }, "repos": { "b7e14472ae": "フォルダーの追加に失敗しました", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3971c49fda9..66435772ac5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -222,7 +222,12 @@ "0216895fb5": "브랜치를 삭제하지 못했습니다.", "19db0085fb": "로컬 브랜치가 삭제되었습니다.", "2b0afc7f14": "로컬 {{value0}}을(를) 최신 상태로 유지할 수 없습니다.", - "670864ab52": "로컬 {{value0}}을 최신 상태로 유지" + "670864ab52": "로컬 {{value0}}을 최신 상태로 유지", + "5366d13eec": "Workspace deleted, branch kept", + "2e17f825d4": "Worktree deleted, branch kept", + "78e08cd877": "Git could not safely delete branch \"{{value0}}\", so Orca kept it to avoid losing local commits.", + "3b57982bf6": "Git could not safely delete branch \"{{value0}}\" after deleting workspace \"{{value1}}\", so Orca kept it to avoid losing local commits.", + "81f13f48d2": "Git could not safely delete branch \"{{value0}}\" after deleting worktree \"{{value1}}\", so Orca kept it to avoid losing local commits." }, "repos": { "b7e14472ae": "폴더를 추가하지 못했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 8b85c2e4ea7..e07d3e89490 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -24,7 +24,7 @@ "codexToggleDescription": "显示 Codex Token 和成本使用情况。", "geminiToggleDescription": "显示Gemini Token 和成本使用情况。", "opencodeGoToggleDescription": "显示OpenCode Go Token 和成本使用情况。", - "kimiToggleDescription": "Kimi 订阅", + "kimiToggleDescription": "显示当前工作区的 Kimi 订阅使用情况。", "sshToggleDescription": "当有可用的 SSH 和远程 Orca 主机时显示它们。", "resourceUsageToggleDescription": "显示资源管理器。点击可查看 CPU、内存、会话、守护进程控制和工作区磁盘扫描。", "portsToggleDescription": "显示实时工作区端口。单击它可获取工作区范围的端口和外部侦听器。" @@ -222,15 +222,20 @@ "0216895fb5": "删除分支失败", "19db0085fb": "本地分支已删除", "2b0afc7f14": "无法使本地 {{value0}} 保持最新", - "670864ab52": "使本地 {{value0}} 保持最新" + "670864ab52": "使本地 {{value0}} 保持最新", + "5366d13eec": "Workspace deleted, branch kept", + "2e17f825d4": "Worktree deleted, branch kept", + "78e08cd877": "Git could not safely delete branch \"{{value0}}\", so Orca kept it to avoid losing local commits.", + "3b57982bf6": "Git could not safely delete branch \"{{value0}}\" after deleting workspace \"{{value1}}\", so Orca kept it to avoid losing local commits.", + "81f13f48d2": "Git could not safely delete branch \"{{value0}}\" after deleting worktree \"{{value1}}\", so Orca kept it to avoid losing local commits." }, "repos": { "b7e14472ae": "添加文件夹失败", - "e649269645": "使用服务器路径从远程运行时添加项目。", + "e649269645": "使用\"添加项目\"在所选主机上输入路径。", "c6e022ddfc": "添加项目失败", "90d129b48b": "文件夹已添加", "8bb3ad7935": "项目已添加", - "a8e4b3af5b": "项目已添加", + "a8e4b3af5b": "项目已添加过了", "6d3318e813": "导入存储库失败" }, "settings": { @@ -504,10 +509,10 @@ } }, "projectSkillRuntime": { - "wslUnavailable": "Project runtime needs WSL before this skill can be installed.", - "distroRequired": "Select a WSL distro for this project before installing this skill.", - "distroMissing": "The selected WSL distro is unavailable. Choose an available distro or switch this project to Windows.", - "wslDefault": "WSL default" + "wslUnavailable": "项目运行时需要 WSL 才能安装此技能。", + "distroRequired": "安装此技能前,请为此项目选择一个 WSL 发行版。", + "distroMissing": "所选 WSL 发行版不可用。请选择可用发行版,或将此项目切换到 Windows。", + "wslDefault": "WSL 默认" } }, "hooks": { @@ -531,10 +536,10 @@ "38c9f034ff": "无法上传删除的文件。", "d720e2f855": "部分已删除的文件无法上传。", "245faa95b9": "没有可用于放置的文件的远程工作区路径。", - "nativeDropTooManyPathsDescription": "Drop {{value0}} or fewer files at a time.", - "nativeDropTooManyPaths": "Drop contains too many files.", - "nativeDropPathsTooLargeDescription": "Drop fewer files or use a shorter path list.", - "nativeDropPathsTooLarge": "Drop path list is too large." + "nativeDropTooManyPathsDescription": "每次最多拖放 {{value0}} 个文件。", + "nativeDropTooManyPaths": "拖放包含过多文件。", + "nativeDropPathsTooLargeDescription": "请减少文件数量或使用更短的路径列表。", + "nativeDropPathsTooLarge": "拖放的路径列表过大。" }, "useIpcEvents": { "0e3cf53060": "未找到浏览器选项卡 {{value0}}", @@ -550,11 +555,11 @@ "f8aaf2bde3": "工作区已上传", "2fe88c2e06": "远程工作区同步不可用", "2ec42e1c52": "还没有远程工作区", - "88214a785b": "工作区同步等待本地会话同步并超时", + "88214a785b": "工作区同步等待本地会话恢复(hydration)并超时", "4f78ba5885": "工作区已同步" }, "useSettingsNavigationMetadata": { - "4a728cd56b": "仍在形成的新功能。尝试一下。", + "4a728cd56b": "仍在完善中的新功能。尝试一下。", "225071c560": "实验性", "e338c507c1": "用于故障排除的低级兼容性设置。", "580a04cd81": "高级", @@ -564,7 +569,7 @@ "d91ae31fbd": "macOS 权限", "95a1886d94": "通过手机控制 terminal 和 Agent。", "1cd25673df": "移动端", - "31e57d1c70": "Use existing machines over SSH for files, terminals, Git, and workspaces.", + "31e57d1c70": "通过 SSH 使用已有机器处理文件、terminals、Git 和工作区。", "94a5afe910": "SSH 主机", "40d80bad8a": "测试版", "de0c2907a1": "远程 Orca 服务器", @@ -602,7 +607,7 @@ "17005c73d4": "打开入门清单以了解设置和里程碑步骤。", "ded9e9032f": "入门清单", "5f32ac08f3": "完成 Orca 核心工作流程的入门清单。", - "8ac3de82f5": "使用设备上模型进行本地语音到文本听写。", + "8ac3de82f5": "使用设备端模型进行本地语音转文字听写。", "6a50cdcd7c": "语音", "0059bd17f3": "使 Agent 能够控制您计算机上的任何应用程序。", "b35e92364b": "计算机控制", @@ -615,21 +620,21 @@ "b49abbd2f7": "Agents" }, "useAppMenuPaste": { - "pasteTooLarge": "Paste is too large." + "pasteTooLarge": "粘贴内容过大。" }, "useLargeTextControlPaste": { - "pasteTooLarge": "Paste is too large." + "pasteTooLarge": "粘贴内容过大。" } }, "components": { "CodexRestartChip": { "9132779820": "关闭", "c72a5fb234": "重新启动", - "9263e75f49": "Codex 正在使用以前的账户", - "a4c8e1b2f7": "Codex is still signed in as {{value0}}", - "d3e8a1f4b2": "Account switched", - "e9b2c7d1a5": "Restart this session to use {{value0}}. Collapse to keep working with the current account.", - "f1a6c8e3b4": "Collapse" + "9263e75f49": "Codex 正在使用之前的账户", + "a4c8e1b2f7": "Codex 仍以 {{value0}} 身份登录", + "d3e8a1f4b2": "账户已切换", + "e9b2c7d1a5": "重启此会话以使用 {{value0}}。折叠以继续使用当前账户。", + "f1a6c8e3b4": "折叠" }, "FirstLaunchBanner": { "b9e1b966c7": "关闭通知", @@ -816,7 +821,7 @@ "ba8e329d92": "取消标记已查看", "3f79ffc8b7": "打开 PR 详情以查看当前评审人。", "5c1c973855": "移除评审人", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "评论过长,无法安全提交。" }, "GitLabItemDialog": { "65e784c1f1": "重新打开", @@ -881,7 +886,7 @@ "3a051b8ade": "工作项目", "32f8bef818": "无日志输出。", "4168eb2c51": "解决", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "评论过长,无法安全提交。" }, "JiraIssueWorkspace": { "b0b92666c9": "评论", @@ -916,7 +921,7 @@ "2a829a2f00": "优先事项", "693be070d0": "过渡", "ef21405c6d": "Jira 议题", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "评论过长,无法安全提交。" }, "Landing": { "76a95f7f47": "创建", @@ -938,7 +943,7 @@ "0d0ace8861": "在 GitHub 上加星标", "ec43b38ba7": "已在 GitHub 上加星标", "157bb5ecbb": "打开 GitHub", - "preflightDismiss": "Dismiss" + "preflightDismiss": "关闭" }, "LinearIssueMarkdownDescriptionEditor": { "d9c47069ef": "Markdown", @@ -1039,7 +1044,7 @@ "39883467f4": "Linear 议题", "fda549766e": "{{value0}} 发表评论", "d71cd3003e": "+ 负责人", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "评论过长,无法安全提交。" }, "NewWorkspaceComposerCard": { "cbb47ee0dc": "仅适用于本地 Git 项目。", @@ -1095,9 +1100,9 @@ "setupHostExistingFolderHelp": "链接已存在的检出目录,然后在该主机上创建此工作区。", "importingHostSetup": "导入中...", "importHostSetup": "导入", - "notePasteTooLarge": "Paste is too large for the note field.", - "reuseExistingBranch": "Reuse branch", - "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it." + "notePasteTooLarge": "笔记字段的粘贴内容过大。", + "reuseExistingBranch": "复用分支", + "reuseExistingBranchHint": "检出已有分支,而不是基于它创建新分支。" }, "NewWorkspaceComposerModal": { "fa90f739a5": "创建工作区之前选择项目、工作区名称和 Agent。" @@ -1267,7 +1272,7 @@ "2b4fdb880c": "取消标记已查看", "56ec6eafb7": "打开 PR 详情以查看当前评审人。", "7f964a365a": "移除评审人", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "评论过长,无法安全提交。" }, "QuickOpen": { "1dbd3f59ff": "手机", @@ -1358,8 +1363,8 @@ "7d08e8be0f": "开始", "af9e877f30": "无标签", "d6cda23ef1": "会员", - "cfaadb6b22": "无铅", - "34da8ac06c": "带领", + "cfaadb6b22": "无负责人", + "34da8ac06c": "负责人", "579f98afcd": "添加简短摘要...", "ecbcc83140": "项目名称", "b6795e65fd": "关闭", @@ -1386,7 +1391,7 @@ "37e7ee311e": "钥匙", "b7bae28b6a": "显示", "a26a48252e": "显示属性", - "5d2d835467": "订购", + "5d2d835467": "排序", "5659da12fc": "分组", "9c57663908": "查看", "af377b13b1": "{{value0}} 视图", @@ -1398,7 +1403,7 @@ "bc06ed0fb0": "返回视图", "3cb855080f": "意见", "b4e10f096e": "所有者", - "a04fe7ba73": "能见度", + "a04fe7ba73": "可见性", "0aa8525950": "模型", "dfc0c79bd8": "议题", "8a07f21e76": "健康", @@ -1417,7 +1422,7 @@ "00b7ffb952": "类型/状态", "eb10c32872": "ID", "e9b6955dcd": "议题 #{{value0}}", - "a0544fb653": "先生!{{value0}}", + "a0544fb653": "MR !{{value0}}", "8396825a14": "操作", "c1d1600362": "在浏览器中打开", "b6329379ca": "启动新工作区", @@ -1437,7 +1442,7 @@ "c679af7ad9": "刷新我的待办事项", "dfd72673e7": "无法保存项目选择。", "b797bdd7c3": "清除搜索", - "99c2755218": "Jira JQL,例如项目 = ABC AND statusCategory != 完成", + "99c2755218": "Jira JQL,例如 project = ABC AND statusCategory != Done", "2ff9fd71fd": "刷新 Jira 议题", "0b65d3fb2c": "搜索 Linear 项目...", "eec0c5c079": "搜索 Linear 议题...", @@ -1515,7 +1520,7 @@ "d45a910c4a": "将 Linear 状态从 {{value0}} 更改为", "d8a517ad89": "标识符", "50387522d7": "没有分组", - "d747aed72f": "木板", + "d747aed72f": "看板", "a6f7e93d7f": "列表", "e78ec261ed": "意见", "727069bee5": "项目", @@ -1528,7 +1533,7 @@ "11a828abf8": "GitLab", "acef77f7ca": "GitHub", "524f095d55": "待评审", - "7698af5263": "矿", + "7698af5263": "我的", "94f0339621": "分配给我", "c2268a9982": "全部", "37a82eaaf8": "合并", @@ -1536,7 +1541,7 @@ "a70153f583": "连接", "6459faa8b3": "错误", "e15ba2d2eb": "创建议题", - "3b11c8e8fc": "大批", + "3b11c8e8fc": "数组", "e178c0a953": "在创建议题之前选择一个 Jira 项目。", "0f7b0d964a": "在 {{value0}} 中创建一个新议题。", "eff9800d4b": "{{value0}} 标签{{value1}}", @@ -1545,7 +1550,7 @@ "7719d8daa9": "{{value0}} 成员{{value1}}", "5af6f0ae5b": "选择团队", "cfb730b73e": "议题", - "3659f9792a": "木板", + "3659f9792a": "看板", "d079be2dc8": "没有分配的议题。请尝试搜索。", "2bdefbcac3": "尝试不同的搜索查询。", "25ff84769a": "没有议题与此 Linear 上下文匹配。", @@ -1558,17 +1563,17 @@ "94d900518d": "没有与所选预设匹配的议题。", "f51e254d35": "尝试不同的 JQL 查询。", "4645a7814f": "Jira", - "e224d76876": "先生", - "bbec4717ee": "先生", + "e224d76876": "MR", + "bbec4717ee": "mr", "d6d08c1650": "选择一个项目以查看 GitLab 工作项。", "f294c500ef": "没有 GitLab 工作与此搜索器匹配。", "cd7dc432a3": "没有 GitLab MR 与此搜索器匹配。", - "171b7739d8": "太太", + "171b7739d8": "mrs", "a9f256ecea": "没有与此搜索器匹配的 GitLab 议题。", "2007e14d95": "GitLab", "456b8512da": "合并请求", "03da966159": "选择一个项目,以便我们可以向 GitLab 进行身份验证。", - "d591aac6ae": "没有待办事项。你们都被抓住了!", + "d591aac6ae": "没有待办事项,你全部处理完啦!", "33a4bd7f5c": "待办事项", "66ae7330f6": "更多操作", "93d5f21fc1": "PR", @@ -1768,11 +1773,11 @@ "943b3dadc9": "该 repo 没有议题类型。", "c7b059cf07": "议题类型", "c5f949e489": "议题", - "8d669084f6": "受限制的", + "8d669084f6": "受限", "6efdc0d920": "草稿", "d0d0e13a5a": "PR", "af5d8c912a": "限制物品", - "bb7ebc11e3": "添加号码", + "bb7ebc11e3": "添加编号", "9cb1a0c984": "添加文字", "2e26a06c70": "添加标签", "36341ffc66": "分配", @@ -1793,8 +1798,8 @@ "72a05c04a6": "正在加载视图...", "9bf55fa1e8": "选择一个视图", "a51b3337ab": "← 返回", - "8ab5447c64": "别针", - "5009ffc2f3": "移除销钉", + "8ab5447c64": "固定", + "5009ffc2f3": "取消固定", "fce99a24a7": "添加", "5113ecc298": "通过 URL 或所有者/号码添加", "7b6d39627e": "加载中…", @@ -1807,11 +1812,11 @@ "ab1a2c357d": "路线图(不支持)", "d34ef9b554": "看板(不支持)", "43a88ae574": "板布局", - "1a2b8e512e": "桌子", + "1a2b8e512e": "表格", "cafb908f34": "表布局" }, "ProjectRow": { - "75b5d816e3": "动手", + "75b5d816e3": "开始工作", "e12be8b4d4": "在 GitHub 中打开", "c3b81ddea2": "DRAFT_ISSUE" }, @@ -1824,7 +1829,7 @@ "ProjectViewWrapper": { "463f1205c0": "加载项目视图", "23b87ba9f7": "在 GitHub 中打开", - "4d2a77a119": "文件功能请求", + "4d2a77a119": "提交功能请求", "1bf8c01c8b": "切换到表格视图以在 Orca 中处理此项目。", "55de4fb57a": "{{value0}}。 {{value1}} 在 {{value2}} 提交功能请求。", "2edf5e7e77": "{{value0}} — Orca 尚不支持 {{value1}} 项目视图。在 {{value2}} 提交功能请求。", @@ -1838,7 +1843,7 @@ "a8fa0d2bf5": "刷新中", "fd15491034": "在 GitHub 中打开视图", "22df63c393": "您的 Token 无法获取子议题数据。", - "067119985c": "GitHub 搜索,例如负责人:@我是:开放", + "067119985c": "GitHub 搜索,例如 assignee:@me is:open", "1850fceac8": "{{value0}}/{{value1}} 未添加到 Orca。添加它以开始工作,或在 GitHub 中打开。", "1aa7c952b9": "项目视图" }, @@ -1857,7 +1862,7 @@ "463d030ae4": "删除", "8564f58542": "编辑", "5f104bf855": "还没有评论。", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "评论过长,无法安全提交。" }, "LabelsEditor": { "34dd57d6c8": "加载中…", @@ -1872,7 +1877,7 @@ "e4ef8281e9": "加载中…", "ae98897edf": "关闭", "69caf40ae8": "在 GitHub 中打开", - "7c302f8174": "无题" + "7c302f8174": "无标题" } } } @@ -1886,7 +1891,7 @@ "8f1c2d4e6a": "没有可预览的内容", "c91f0a2b14": "编写", "d82b1e3f05": "预览", - "imageUrlTooLarge": "Image URL is too large." + "imageUrlTooLarge": "图片 URL 过长。" }, "IssueSourceSelector": { "d6aeb2012b": "显示议题来自", @@ -1925,11 +1930,11 @@ "b1d9fdea08": "标签", "24754c44ad": "作者", "764a0b4ce1": "状态", - "f0cf6dd591": "离开", - "1e9b5244f2": "在", + "f0cf6dd591": "关", + "1e9b5244f2": "开", "b930de7194": "仅草稿", "e0002f1eba": "已选择", - "2b2f019091": "任何州", + "2b2f019091": "任意状态", "0fd3249e2e": "已关闭", "d78b60b5c2": "进行中", "bd162b7d5a": "合并", @@ -1955,7 +1960,7 @@ "1daf0f22a9": "GraphQL", "1f2f28a4de": "搜索接口", "c377a4f06a": "搜索", - "c392c749a6": "休息API", + "c392c749a6": "REST API", "bb227706a6": "REST", "budget_scope_prefix": "预算范围" } @@ -1977,7 +1982,7 @@ "b1c2d3e4f5": "重新打开议题", "0a73f59e85": "发送评论", "bf43425540": "评论", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "评论过长,无法安全提交。" }, "GitHubWorkItemAssigneePopoverContent": { "cddd9b04a7": "加载负责人", @@ -2019,7 +2024,7 @@ "edec49dfae": "lin_api_...", "7d498f653c": "个人API密钥", "57a66522c8": "连接", - "c9889a09f8": "在创建关键帧之前,使用 Linear 选择所需的工作区。", + "c9889a09f8": "在创建密钥之前,使用 Linear 选择所需的工作区。", "e689a4d0a6": "错误" } } @@ -2039,7 +2044,7 @@ "c5f79616c3": "团队", "25a2196732": "目标", "3fb6473111": "开始", - "111bef9aa8": "带领", + "111bef9aa8": "负责人", "3be47aed6f": "优先事项", "f5ef24cf46": "健康", "9ddb58edbd": "状态", @@ -2138,7 +2143,7 @@ "38ca0b1400": "这将永久删除他们的本地文件。您无法撤销此操作。", "c4f4782c02": "不建议", "0a2e3c7cba": "评审", - "e97e4580c7": "肮脏的", + "e97e4580c7": "有改动", "9623a5107d": "未推送的 commits", "e8b3741ff7": "被忽略", "a9957007eb": "忽略 {{value0}}", @@ -2160,7 +2165,7 @@ "37ab28277e": "不建议", "1b18868569": "待评审", "b299f201b9": "安全移除", - "2b31bf68de": "不活跃的", + "2b31bf68de": "非活动", "ac5ba84cc1": "已选择", "8b74d4ea6e": "扫描工作树和 git 状态,然后在建议删除之前结合打开的选项卡、terminal、实时 Agent 和远程可用性信号。", "7eee951968": "检查工作区安全", @@ -2172,9 +2177,9 @@ "0f00612b6d": "删除了 {{value0}} 工作区 {{value1}}", "7f451a3e2c": "无法忽略清理建议", "662b8ec3f8": "工作区清理扫描失败", - "bc43c37faf": "隐", + "bc43c37faf": "隐藏", "0c6672f5e3": "忽略清理建议", - "fc49f79434": "混合的", + "fc49f79434": "混合", "2ddbd6fe8a": "检查过", "ee81adfcef": "查看", "4d0b72481c": "忽略", @@ -2233,7 +2238,7 @@ "925b8e0f6e": "高级", "97e96cc027": "/目标", "e604bd40d6": "支持技能、文件路径和内置命令,例如", - "79af0c0841": "npm 运行开发", + "79af0c0841": "npm run dev", "577a342c7d": "要求 Agent 调查此工作区", "026cfb232a": "不支持提示命令", "346d409ab2": "选择 Agent", @@ -2241,7 +2246,7 @@ "ec8f081919": "操作", "ed04233b3e": "保存 terminal 命令或 Agent 提示以便快速访问。", "ca414324ee": "命令文本", - "dc921c17ee": "迅速的", + "dc921c17ee": "提示", "5b3f634a55": "添加快捷命令", "f9b184fc16": "编辑快速命令", "6751598542": "编辑" @@ -2260,7 +2265,7 @@ "2496523a6f": "选择项目", "2264edd5d3": "项目不在列表中", "3834d24243": "项目", - "b83efc79e2": "全球的", + "b83efc79e2": "全局", "c25cf350ef": "范围", "f0631e4999": "repo" } @@ -2272,13 +2277,13 @@ "1d1a7a9c1f": "取消", "6b9a6975f8": "terminal 仍有正在运行的进程。如果关闭 terminal,该进程将被终止。", "78b79d854d": "关闭 Terminal?", - "stop_agent_title": "Stop this agent?", - "stop_command_title": "Stop running command?", - "stop_agent_description": "Closing this terminal will stop the agent's current work.", - "stop_command_description": "Closing this terminal will stop the command running inside it.", - "dont_ask_again": "Don't ask again for running terminals", - "stop_agent_confirm": "Stop Agent", - "stop_command_confirm": "Stop and Close" + "stop_agent_title": "停止此 Agent?", + "stop_command_title": "停止运行中的命令?", + "stop_agent_description": "关闭此 terminal 将停止 Agent 当前任务。", + "stop_command_description": "关闭此 terminal 将停止其中正在运行的命令。", + "dont_ask_again": "对有运行中进程的 terminals 不再询问", + "stop_agent_confirm": "停止 Agent", + "stop_command_confirm": "停止并关闭" }, "MobileDriverOverlay": { "c6460cf584": "收回", @@ -2307,7 +2312,7 @@ "TerminalContextMenu": { "b4cdd9314e": "清晰的屏幕", "8c17d6786d": "关闭窗格", - "copyTerminalId": "Copy Terminal ID", + "copyTerminalId": "复制 Terminal ID", "2cf85a6a55": "复制窗格 ID", "39809d152f": "设置标题...", "06c2b0f043": "均衡窗格大小", @@ -2383,11 +2388,11 @@ "29c031b49a": "正在上传 {{value0}} 文件{{value1}} 到运行时...", "0c77693641": "工作树尚未准备好 - 请稍后再试。", "ce8248b835": "工作树路径不可用。", - "internalTooManyPaths": "Drop contains too many paths for a safe terminal paste.", - "internalPathsTooLarge": "Drop path list is too large for a safe terminal paste.", - "b4cf68e889": "Skipped {{value0}} {{value1}}.", - "writeTimeout": "File drop cancelled: terminal did not accept the path before the safety timeout.", - "writeRejected": "File drop cancelled: terminal could not accept the path." + "internalTooManyPaths": "拖放的路径过多,无法安全粘贴到 terminal。", + "internalPathsTooLarge": "拖放的路径列表过大,无法安全粘贴到 terminal。", + "b4cf68e889": "已跳过 {{value0}} {{value1}}。", + "writeTimeout": "文件拖放已取消:terminal 在安全超时前未接受路径。", + "writeRejected": "文件拖放已取消:terminal 无法接受路径。" } } }, @@ -2434,11 +2439,11 @@ "586d2ac445": "terminal" }, "AiVaultSessionDropLayer": { - "dropOntoTerminalPane": "Drop onto a terminal pane to resume this session.", - "couldNotReadPayload": "Could not read the session drag payload.", - "localWorkspacesOnly": "Resume from history is only available in local workspaces.", - "openLocalWorkspace": "Open a local workspace before resuming a session.", - "sessionQueued": "Session queued" + "dropOntoTerminalPane": "拖放到 terminal 面板以恢复此会话。", + "couldNotReadPayload": "无法读取会话拖拽数据。", + "localWorkspacesOnly": "从历史恢复仅支持本地工作区。", + "openLocalWorkspace": "恢复会话前请先打开一个本地工作区。", + "sessionQueued": "会话已排队" } }, "bar": { @@ -2571,7 +2576,7 @@ "5a9c83c04b": "打开任何文件、URL、Agent...", "90eb94dc48": "输入 http:// 或 https:// URL。", "5553b283ce": "输入 URL 或文件路径。", - "queryTooLarge": "Search text is too large." + "queryTooLarge": "搜索文本过长。" } }, "menu": { @@ -2702,12 +2707,12 @@ "63f36455cc": "连接", "bf07aee59e": "断开连接失败", "2c29e2de68": "连接失败", - "bc5a3fd41a": "部分的", + "bc5a3fd41a": "部分", "3d0128b105": "已连接", "fd9a3c600e": "错误", "fbb3f9f05e": "冲突", - "95e4ff5b4b": "推动", - "63a2b965f6": "拉", + "95e4ff5b4b": "推送中", + "63a2b965f6": "拉取中", "remote_server": "远程服务器", "runtime_checking": "检查中", "runtime_online": "已连接", @@ -2715,8 +2720,8 @@ "runtime_connect_unavailable": "远程主机不可达", "runtime_disconnect_failed": "断开连接失败", "runtime_reconnecting": "重新连接中", - "runtime_last_close_reason": "Closed: {{value0}}", - "runtime_reconnect_attempt": "Attempt {{value0}}" + "runtime_last_close_reason": "已关闭:{{value0}}", + "runtime_reconnect_attempt": "第 {{value0}} 次尝试" }, "StatusBar": { "9659e38343": "端口", @@ -2982,9 +2987,9 @@ "cfe2282ffa": "未知", "7765a4c3e1": "不适用", "2d41fd45c6": "• 上次扫描错误:{{value0}}", - "rangeLast7Days": "Last 7 days", - "rangeLast30Days": "Last 30 days", - "rangeLast90Days": "Last 90 days", + "rangeLast7Days": "过去 7 天", + "rangeLast30Days": "过去 30 天", + "rangeLast90Days": "过去 90 天", "rangeAllTime": "全部时间" }, "CodexUsageDailyChart": { @@ -3035,9 +3040,9 @@ "ae255c3dba": "不适用", "247c93ca92": "• 推断定价", "8a6655f7a2": "• 上次扫描错误:{{value0}}", - "rangeLast7Days": "Last 7 days", - "rangeLast30Days": "Last 30 days", - "rangeLast90Days": "Last 90 days", + "rangeLast7Days": "过去 7 天", + "rangeLast30Days": "过去 30 天", + "rangeLast90Days": "过去 90 天", "rangeAllTime": "全部时间" }, "OpenCodeUsagePane": { @@ -3078,9 +3083,9 @@ "362231082f": "未知", "8095a63426": "不适用", "6cc7782458": "• 上次扫描错误:{{value0}}", - "rangeLast7Days": "Last 7 days", - "rangeLast30Days": "Last 30 days", - "rangeLast90Days": "Last 90 days", + "rangeLast7Days": "过去 7 天", + "rangeLast30Days": "过去 30 天", + "rangeLast90Days": "过去 90 天", "rangeAllTime": "全部时间" }, "ShareUsageButton": { @@ -3222,7 +3227,7 @@ "SparseCheckoutPresetSelect": { "c4ac80151d": "新预设", "7c3275d307": "编辑 {{value0}}", - "c7f9b3f0c1": "离开", + "c7f9b3f0c1": "关", "8b12c0850a": "保存", "de8fce5854": "取消", "ddbcaef7be": "src/渲染器包/ui", @@ -3815,7 +3820,7 @@ "e888362def": "状态: 关闭", "f394b3e86e": "状态:合并", "af2b07bda5": "州:{{value0}}", - "29df45afa2": "先生" + "29df45afa2": "MR" }, "WorktreeCardPorts": { "34f733dda2": "转到工作树", @@ -3849,8 +3854,8 @@ "84cdbb7e30": "移至状态", "56cde9e8e6": "将状态移至", "f50603c6b2": "标记为未读", - "8dacff1fe0": "马克·里德", - "3baa7d6507": "别针", + "8dacff1fe0": "标记为已读", + "3baa7d6507": "固定", "697d0f6e1b": "取消固定", "250de158fd": "移除工作区", "changeParentWorkspace": "更改父工作树...", @@ -4356,7 +4361,7 @@ "e4a28e8894": "Codex 报告 {{value0}} 登录需要重新登录。在开始新的 Codex 会话之前再次登录。", "75ca9b718e": "Codex 报告活动账户需要重新登录。在开始新的 Codex 会话之前重新对其进行身份验证。", "b11078a9c2": "wsl", - "350b2a1aa7": "Use your current", + "350b2a1aa7": "用你已有的", "e05d0ff737": "使用您当前 {{value0}} Claude 登录名。" }, "AdvancedPane": { @@ -4381,19 +4386,19 @@ "f97b986b7f": "wsl" }, "AgentSkillSetupPanel": { - "0b810ec59f": "Press Enter to run the command.", + "0b810ec59f": "按 Enter 运行命令。", "ed197f59a2": "复制命令", - "817d3f9f18": "Copy command", + "817d3f9f18": "复制命令", "5289300939": "未安装", "9fcebceb2a": "已安装", "68a468752e": "检查中...", "c689392435": "重新检查", - "a31e2aa302": "Failed to copy command.", - "378ad26865": "Copied command.", - "copiedCommand": "Copied command.", - "failedToCopyCommand": "Failed to copy command.", - "copyCommandAria": "Copy command", - "runCommandDescription": "Press Enter to run the command." + "a31e2aa302": "复制命令失败。", + "378ad26865": "已复制命令。", + "copiedCommand": "已复制命令。", + "failedToCopyCommand": "复制命令失败。", + "copyCommandAria": "复制命令", + "runCommandDescription": "按 Enter 运行命令。" }, "AgentsPane": { "d83834f5e6": "检测已安装的 Agent...", @@ -4405,7 +4410,7 @@ "92033495ff": "自动", "9b175d0f5e": "打开新工作区时预先选择的 Agent。", "385212c7a1": "默认 Agent", - "f9f127d664": "Override the binary path or name, and edit the default launch arguments or environment for this agent.", + "f9f127d664": "覆盖二进制路径或名称,并编辑此 agent 的默认启动参数或环境。", "f95b5c79b8": "安装", "fe4d630c94": "文档", "8dc0192e48": "已禁用", @@ -4428,14 +4433,14 @@ "cfb3f35775": "参数", "6f99bf5dd0": "无默认参数", "8fbe1f37c1": "环境", - "2d133152fa": "No default environment", - "agentPermissions": "Agent Permissions", - "agentPermissionsInfo": "Agent permissions info", + "2d133152fa": "无默认环境", + "agentPermissions": "Agent 权限", + "agentPermissionsInfo": "Agent 权限信息", "agentPermissionsTooltip": "Custom agent arguments stay unchanged when switching modes.", - "agentPermissionsDescription": "Choose whether Orca launches agents with fewer permission prompts or with manual checks.", + "agentPermissionsDescription": "选择 Orca 是以更少的权限提示启动 agent,还是手动检查。", "agentPermissionsYolo": "Yolo", - "agentPermissionsManual": "Manual", - "3f1bdf3cb4": "Environment text is too large to parse safely." + "agentPermissionsManual": "手动", + "3f1bdf3cb4": "环境文本过长,无法安全解析。" }, "AppIconSelector": { "d5a112dc9b": "下一个图标", @@ -4578,9 +4583,9 @@ "7b649a578a": "创建中…", "4399c77caa": "默认", "4af9a17947": "kagi", - "c0f85056d9": "Browser profiles on this Orca server.", - "86b7c83fee": "This computer", - "6480776a03": "Browser profiles for the selected host.", + "c0f85056d9": "此 Orca 服务器上的浏览器配置。", + "86b7c83fee": "本机", + "6480776a03": "所选主机的浏览器配置。", "5e19a692f7": "主机" }, "BrowserProfileRow": { @@ -4883,8 +4888,8 @@ "d58fccfd84": "导航", "5cb5475664": "关闭固定标签页前确认", "36b2a5dc6d": "关闭固定标签页前显示确认对话框。", - "projectRuntime": "Project Runtime", - "projectRuntimeDescription": "Default runtime for local Windows projects that do not override it." + "projectRuntime": "项目运行时", + "projectRuntimeDescription": "不覆盖此项的本地 Windows 项目的默认运行时。" }, "GeneralSupportSection": { "af7d9f4396": "感谢您的支持!", @@ -5031,7 +5036,7 @@ "2c0330ec3e": "对于私有存储库,并设置", "e678d89e8c": "ORCA_GITEA_TOKEN", "d9467ab026": "公共仓库通过其 git 远程检测。设置", - "4ab9b96925": "吉泰亚", + "4ab9b96925": "Gitea", "953b7bf6f7": "Azure DevOps 凭据已配置,但无法进行身份验证。检查令牌、API 基本 URL 和存储库权限,如果环境变量发生更改,则重新启动 Orca。", "6f317f5132": "仅当 Orca 无法从 git 远程派生 API 基本 URL 时。", "ae6b7f5f40": "ORCA_AZURE_DEVOPS_API_BASE_URL", @@ -5039,8 +5044,8 @@ "8f960935c1": "ORCA_AZURE_DEVOPS_ACCESS_TOKEN", "ce3c58cd63": ",或设置", "5ee6ef6405": "ORCA_AZURE_DEVOPS_TOKEN", - "4ee74d1470": "放", - "5efce6953d": "Azure 开发运营", + "4ee74d1470": "设置", + "5efce6953d": "Azure DevOps", "3c3cf05c63": "Bitbucket 凭据已配置,但无法进行身份验证。检查令牌和存储库权限,如果环境变量发生更改,则重新启动 Orca。", "6e0ff3403e": "ORCA_BITBUCKET_ACCESS_TOKEN", "44cde4aa01": "ORCA_BITBUCKET_API_TOKEN", @@ -5087,10 +5092,10 @@ "a565377c38": "未安装", "15cf990798": "未经过验证", "f7eb5f0b24": "未安装", - "3ba07f933b": "Connect issue trackers Orca can use to browse tasks and start workspaces with linked context.", - "70e885705b": "Task providers", - "1683acbac4": "Connect the source hosts Orca can use for pull requests, merge requests, checks, and review status.", - "298c65ecac": "Review providers" + "3ba07f933b": "连接 Orca 可用于浏览任务并启动带链接上下文工作区的议题追踪器。", + "70e885705b": "任务提供商", + "1683acbac4": "连接 Orca 可用于 pull request、merge request、检查和评审状态的源代码主机。", + "298c65ecac": "评审提供商" }, "KagiSessionLinkForm": { "92f0b4e472": "清除", @@ -5534,27 +5539,27 @@ "ee5a290616": "作为文件夹打开。 Git 功能对此工作区不可用。", "323debba71": "类型:", "499a437335": "身份", - "availableHosts": "Available Hosts", - "availableHostsDescription": "Hosts where this project is set up.", - "availableHostsHelp": "Project paths and worktree settings are host-specific; creating a workspace can target any ready setup.", - "viewingHost": "Viewing host", + "availableHosts": "可用主机", + "availableHostsDescription": "已设置此项目的主机。", + "availableHostsHelp": "项目路径和工作树设置按主机独立;创建工作区时可指向任意已就绪的设置。", + "viewingHost": "查看主机", "currentSetup": "Current", "hostSetupStateReady": "Ready", - "hostSetupStateNotSetUp": "Not set up", - "hostSetupStateSettingUp": "Setting up", - "hostSetupStateError": "Error", - "hostSetupStateUnsupported": "Unsupported", - "setupPathPending": "Path pending", + "hostSetupStateNotSetUp": "未设置", + "hostSetupStateSettingUp": "设置中", + "hostSetupStateError": "错误", + "hostSetupStateUnsupported": "不支持", + "setupPathPending": "路径待定", "openSetup": "Open", "removeSetup": "Remove", - "hostSetupBlockedVersion": "Orca server version is incompatible", - "hostSetupMissingCapability": "Update Orca on this host to set up projects", - "setupProjectOnHost": "Set up on another host", - "setupProjectOnHostHelp": "Choose a host, then import an existing checkout, clone the repository there, or track a setup that will be provisioned later.", + "hostSetupBlockedVersion": "Orca 服务器版本不兼容", + "hostSetupMissingCapability": "在此主机上更新 Orca 以设置项目", + "setupProjectOnHost": "在其他主机上设置", + "setupProjectOnHostHelp": "选择一台主机,然后导入已有检出、在那里克隆仓库,或跟踪稍后才配置的设置。", "setupExistingFolder": "导入现有文件夹", - "setupExistingFolderHelp": "Make this project available on another host by linking a checkout that already exists there.", + "setupExistingFolderHelp": "通过链接那台主机上已有的检出,让此项目在其他主机也可用。", "setupExistingFolderPathPlaceholder": "/path/to/project/on/host", - "cloneUrlPlaceholder": "Repository URL", + "cloneUrlPlaceholder": "仓库 URL", "cloneDestinationPlaceholder": "/destination/on/host", "setupKindGit": "Git repo", "setupKindFolder": "文件夹", @@ -5563,27 +5568,27 @@ "cloningHost": "克隆中...", "cloneHost": "克隆", "creatingPendingSetup": "Creating...", - "createPendingSetup": "Track setup", - "hostSetupCheckingCapability": "Checking host capabilities", - "hostAvailability": "Host availability", - "hostAvailabilityHelp": "Add this same project on another connected host.", - "addToAnotherHost": "Add to another host", - "addProjectHost": "Add project to host", - "addProjectHostHelp": "Choose where this project should also be available.", + "createPendingSetup": "跟踪设置", + "hostSetupCheckingCapability": "检查主机能力", + "hostAvailability": "主机可用性", + "hostAvailabilityHelp": "在另一台已连接的主机上添加此项目。", + "addToAnotherHost": "添加到其他主机", + "addProjectHost": "将项目添加到主机", + "addProjectHostHelp": "选择此项目还应在何处可用。", "closeHostSetup": "关闭", "setupHostLabel": "主机", "browseFolder": "浏览文件夹", - "browseFolderHelp": "Use an existing checkout or folder on this host.", - "otherWaysToAdd": "Other ways to add", - "cloneFromUrl": "Clone from URL", - "cloneFromUrlHelp": "Clone this repository onto the selected host.", - "addPlannedHost": "Add host placeholder", - "addPlannedHostHelp": "Remember this host and finish adding the project later.", - "existingFolder": "Existing folder", + "browseFolderHelp": "使用此主机上已有的检出或文件夹。", + "otherWaysToAdd": "其他添加方式", + "cloneFromUrl": "从 URL 克隆", + "cloneFromUrlHelp": "将此仓库克隆到所选主机。", + "addPlannedHost": "添加主机占位符", + "addPlannedHostHelp": "记下此主机,稍后再完成添加项目。", + "existingFolder": "已有文件夹", "addPlannedHostToHost": "Add {{host}}", - "addPlannedHostConfirm": "This only records that the project should be available on this host. You can add the folder or clone later.", - "projectRuntime": "Project Runtime", - "projectRuntimeDescription": "Choose whether this project runs on Windows or WSL." + "addPlannedHostConfirm": "这仅记录项目应在此主机上可用。你可以稍后添加文件夹或克隆。", + "projectRuntime": "项目运行时", + "projectRuntimeDescription": "选择此项目运行在 Windows 还是 WSL。" }, "RepositorySourceControlAiActionRows": { "548a6e1281": "命令模板", @@ -5603,8 +5608,8 @@ "f9941f0caf": "例如ollama 运行 llama3.1 {提示}" }, "RepositorySourceControlAiEnablement": { - "84233d1bb3": "离开", - "bea897eec2": "在", + "84233d1bb3": "关", + "bea897eec2": "开", "62511a575d": "使用全局", "30ae6dcce8": "全局默认是", "cf5959c834": "启用源代码控制 AI", @@ -5614,8 +5619,8 @@ "visibilityHelper": "控制是否为此仓库显示源代码控制 AI 按钮。单独功能使用的生成会遵循这些功能各自的设置。全局默认值为 {{value0}}。" }, "RepositorySourceControlAiHostedReviewDefaults": { - "053ccfbf52": "离开", - "777443bf89": "在", + "053ccfbf52": "关", + "777443bf89": "开", "ffc3b26b26": "使用全局", "a68849a859": "全局默认是", "aa6ee4b7d6": "托管评审创建默认值", @@ -5761,9 +5766,9 @@ "65660d4548": "macOS 权限", "c6c01ac209": "通过手机控制终端和智能体。", "c40dadaac8": "移动端", - "c2ee313198": "Use existing machines over SSH for files, terminals, Git, and workspaces.", + "c2ee313198": "通过 SSH 使用已有机器处理文件、terminals、Git 和工作区。", "9b02492d1f": "SSH 远程主机", - "b5ee17826b": "Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.", + "b5ee17826b": "配对远程 Orca 运行时,以获得持久会话、更丰富的远程状态,以及 Web 或移动端接续。", "7686cb5c36": "将此浏览器连接到已保存的 Orca 服务器。", "bd0181eeca": "远程 Orca 服务器", "8acf3f22e0": "Orca 统计数据以及 Claude、Codex 和 OpenCode 使用情况分析。", @@ -5813,7 +5818,7 @@ "43b68e10f0": "您有未保存的 Git AI Author 更改。离开将丢弃它们。", "17bdee4ff1": "放弃未保存的 Git AI Author 更改?", "084d8fac5b": "隐私与安全", - "23931df7e8": "Remote Hosts", + "23931df7e8": "远程主机", "mobile_group": "移动端", "8bd117d669": "界面", "e1578cd4bc": "工作流程", @@ -5837,10 +5842,10 @@ "cb330ef7f8": "{{value0}} 的", "c822571b2e": "匹配“{{value0}}”", "3119c012a5": "字符串", - "builtin_themes": "Built-in", - "imported_from": "Imported from {{value0}}", - "imported_themes": "Imported", - "search_terminal_themes": "Search terminal themes" + "builtin_themes": "内置", + "imported_from": "从 {{value0}} 导入", + "imported_themes": "已导入", + "search_terminal_themes": "搜索 terminal 主题" }, "SettingsSidebar": { "e0900f83e7": "SSH", @@ -6072,8 +6077,8 @@ "abcb4dd019": "Terminal Cursor", "70beb1bbc7": "预览", "31f6e61085": "目前连字", - "870377082f": "离开", - "84bd22f2cd": "在", + "870377082f": "关", + "84bd22f2cd": "开", "bc9ff84d61": "自动", "be8da35e7f": "字体连字", "4b1f29598e": "自动 - 对“{{value0}}”禁用。", @@ -6092,8 +6097,8 @@ "048aac8a64": "Terminal 排版", "4415beb958": "已禁用", "4e7d41a9f0": "已启用", - "e90afcc44f": "离开", - "16c471ee03": "在" + "e90afcc44f": "关", + "16c471ee03": "开" }, "TerminalFontSizeSetting": { "9b5252c85a": "像素", @@ -6104,7 +6109,7 @@ "4263e940e0": "按 JIS 日元 (¥) 键会发送反斜杠 (\\\\)。", "19f4935159": "JIS 日元 (¥) 至 反斜杠 (\\\\)", "1c337bef4a": "控制按 JIS 日元 (¥) 键是否发送反斜杠 (\\\\)。", - "3fe1c5bfe0": "离开", + "3fe1c5bfe0": "关", "c73d510938": "正确的", "e7aec1fd60": "左边", "badb1219fc": "两个都", @@ -6151,7 +6156,7 @@ "8eefeaa3da": "焦点跟随鼠标", "96fe15def8": "terminal 窗格的鼠标和剪贴板行为。", "45721f3e67": "Terminal 交互", - "9c0b1c1792": "在", + "9c0b1c1792": "开", "c1fc9e9444": "GPU加速", "e0996d141a": "Auto 尝试使用 WebGL,并针对不受支持或有风险的渲染器使用 DOM 回退。", "7eaccc1424": "始终尝试将 WebGL 用于 terminal 窗格。", @@ -6180,11 +6185,11 @@ "16753eea48": "在 Windows 上,右键单击粘贴剪贴板。 Ctrl+右键单击打开上下文菜单。", "9c178cf8aa": "右键单击粘贴", "af0c3b6e39": "在 Windows 上,右键单击将剪贴板粘贴到 terminal 中。使用 Ctrl+右键单击打开上下文菜单。", - "29154326bb": "在", - "ab20575a8a": "离开", + "29154326bb": "开", + "ab20575a8a": "关", "ab3a1f9068": "执行程序", - "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", - "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent." + "ask_before_closing_running_terminals_title": "关闭运行中的 Terminal 前询问", + "ask_before_closing_running_terminals_description": "在关闭有运行中命令或 agent 的 terminal 前显示确认。" }, "TerminalSettingsPreview": { "a63953a48a": "预览 {{value0}} 主题", @@ -6208,8 +6213,8 @@ "7add204bd5": "选择深色模式下使用的 terminal 主题。", "9499ad1dc4": "黑暗主题", "f012172e21": "选择深色模式下用于 terminal 窗格的主题。", - "import_themes_title": "Import Themes", - "import_themes_description": "Imported themes are available in both the dark and light theme pickers." + "import_themes_title": "导入主题", + "import_themes_description": "已导入的主题在深色和浅色主题选择器中都可用。" }, "TerminalWindowSection": { "1705318506": "ANSI 洋红色", @@ -6480,8 +6485,8 @@ "8a17fd6026": "稳定", "a79d266f71": "会话", "afbf35be68": "稳定会话", - "agentPermissions": "Agent Permissions", - "agentPermissionsDescription": "Switch agent permission defaults between Yolo and Manual." + "agentPermissions": "Agent 权限", + "agentPermissionsDescription": "在 Yolo 和手动之间切换 agent 权限默认值。" } }, "appearance": { @@ -6564,8 +6569,8 @@ "f4997e0f8a": "联系", "a278406ed5": "偏僻的", "6ecad74eb3": "SSH", - "f17d66d0d2": "Show remote host connection status in the status bar.", - "57fb424c56": "Remote Hosts", + "f17d66d0d2": "在状态栏显示远程主机连接状态。", + "57fb424c56": "远程主机", "35565867cb": "登月计划", "de586def95": "订阅", "00a028f25f": "用法", @@ -7053,8 +7058,8 @@ "4c95d08fa2": "工作区目录", "161a86a9da": "关闭固定标签页前确认", "8e593f04fc": "关闭固定标签页前显示确认对话框。", - "defaultProjectRuntime": "Default Project Runtime", - "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects." + "defaultProjectRuntime": "默认项目运行时", + "defaultProjectRuntimeDescription": "选择本地 Windows 项目继承的运行时。" } }, "git": { @@ -7520,16 +7525,16 @@ "92af66c7ce": "项目名称", "883aad2801": "侧边栏和选项卡的项目特定显示详细信息。", "7e1e456a95": "显示名称", - "availableHosts": "Available Hosts", - "availableHostsDescription": "Hosts where this project is set up.", + "availableHosts": "可用主机", + "availableHostsDescription": "已设置此项目的主机。", "host": "主机", "ssh": "ssh", "remote": "远程", "vm": "vm", "keepForkUpToDate": "保持 Fork 最新", "keepForkUpToDateDescription": "从 upstream 安全地快进此 Fork。", - "projectRuntime": "Project Runtime", - "projectRuntimeDescription": "Choose whether this project runs on Windows or WSL.", + "projectRuntime": "项目运行时", + "projectRuntimeDescription": "选择此项目运行在 Windows 还是 WSL。", "copy": "copy", "clone": "clone", "apfs": "apfs" @@ -7807,20 +7812,20 @@ "0fe0073f0c": "新窗格和实时更新的默认 terminal 字体大小。", "5930244899": "字体大小", "warp_import": { - "title": "Import themes from Warp", - "description": "Import Warp themes as Orca terminal themes.", + "title": "从 Warp 导入主题", + "description": "将 Warp 主题导入为 Orca terminal 主题。", "keyword_warp": "warp", "keyword_themes": "themes", "keyword_yaml": "yaml" }, "yaml_import": { - "title": "Import from YAML", - "description": "Import theme YAML files as Orca terminal themes.", + "title": "从 YAML 导入", + "description": "将主题 YAML 文件导入为 Orca terminal 主题。", "keyword_yaml": "yaml", "keyword_custom": "自定义" }, - "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", - "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent." + "ask_before_closing_running_terminals_title": "关闭运行中的 Terminal 前询问", + "ask_before_closing_running_terminals_description": "在关闭有运行中命令或 agent 的 terminal 前显示确认。" }, "windows": { "search": { @@ -7937,65 +7942,65 @@ } }, "WarpThemeImportModal": { - "title": "Import themes from Warp", - "description": "Import Warp themes as Orca terminal themes.", - "yaml_title": "Import theme YAML", - "yaml_description": "Import theme YAML files (Warp format) as Orca terminal themes.", - "yaml_no_themes_found": "No themes found in the selected files.", + "title": "从 Warp 导入主题", + "description": "将 Warp 主题导入为 Orca terminal 主题。", + "yaml_title": "导入主题 YAML", + "yaml_description": "将主题 YAML 文件(Warp 格式)导入为 Orca terminal 主题。", + "yaml_no_themes_found": "所选文件中未找到主题。", "choose_file": "选择文件", - "choose_folder": "Choose Folder", + "choose_folder": "选择文件夹", "loading": "正在加载 Warp 主题...", - "found_theme_one": "Found 1 theme", - "found_theme_other": "Found {{value0}} themes", - "found_in_source": " in {{value0}}", - "clear_all": "Clear all", - "select_all": "Select all", - "colors_only": "Colors only", - "no_themes_found": "No custom Warp themes found.", - "builtin_themes_hint": "Warp's preloaded themes are part of the Warp app and can't be read from disk. Orca already includes most of them, like Dracula, Gruvbox, Solarized, and Tokyo Night.", + "found_theme_one": "找到 1 个主题", + "found_theme_other": "找到 {{value0}} 个主题", + "found_in_source": " 位于 {{value0}}", + "clear_all": "全部清除", + "select_all": "全选", + "colors_only": "仅颜色", + "no_themes_found": "未找到自定义 Warp 主题。", + "builtin_themes_hint": "Warp 的预装主题是 Warp 应用的一部分,无法从磁盘读取。Orca 已包含其中大多数,如 Dracula、Gruvbox、Solarized 和 Tokyo Night。", "custom_theme_yaml_hint": "自定义和社区主题必须以 YAML 文件形式存在于 Warp themes 文件夹中,自动导入才能发现它们。如果你克隆了 Warp 的公开主题 repo,请使用 Choose Folder 导入该 repo 副本。", "choose_manually": "选择主题 YAML 文件或文件夹以手动导入。", - "skipped_files": "Skipped files", - "more_skipped_files": "{{value0}} more skipped files.", + "skipped_files": "已跳过文件", + "more_skipped_files": "还有 {{value0}} 个已跳过的文件。", "cancel": "取消", - "import_theme_one": "Import 1 Theme", - "import_theme_other": "Import {{value0}} Themes", - "import_themes": "Import Themes" + "import_theme_one": "导入 1 个主题", + "import_theme_other": "导入 {{value0}} 个主题", + "import_themes": "导入主题" }, "useWarpThemeImport": { "unknown_error": "未知错误", - "imported_one": "Imported 1 theme", - "imported_other": "Imported {{value0}} themes", + "imported_one": "已导入 1 个主题", + "imported_other": "已导入 {{value0}} 个主题", "import_failed": "导入主题失败", - "over_limit_one": "Importing these themes would exceed the {{value0}} custom terminal theme limit. Deselect 1 new theme and try again.", - "over_limit_other": "Importing these themes would exceed the {{value0}} custom terminal theme limit. Deselect {{value1}} new themes and try again." + "over_limit_one": "导入这些主题会超出 {{value0}} 个自定义 terminal 主题的上限。请取消选择 1 个新主题后重试。", + "over_limit_other": "导入这些主题会超出 {{value0}} 个自定义 terminal 主题的上限。请取消选择 {{value1}} 个新主题后重试。" }, "YamlThemeImportButton": { - "label": "Import from YAML" + "label": "从 YAML 导入" }, "cli": { "source": { "control": { "integration": { "cards": { - "d5b3be8ecd": "Re-check", - "8cbc39f862": "Learn more", + "d5b3be8ecd": "重新检查", + "8cbc39f862": "了解更多", "707180d09c": "glab auth login", - "4be0616873": "The GitLab CLI is installed but not authenticated. Run this command in a terminal:", + "4be0616873": "GitLab CLI 已安装但未通过身份验证。请在 terminal 中运行此命令:", "54a640af7a": "Install GitLab CLI", "b56fd5676a": "Install the GitLab CLI to enable merge requests, issues, and pipelines.", - "faddeb763d": "GitLab CLI status is not available in this runtime yet.", - "a47f71e357": "CLI.", + "faddeb763d": "此运行时暂不支持 GitLab CLI 状态。", + "a47f71e357": "CLI。", "2a6b359e75": "glab", - "1f2b347bd3": "Merge requests, issues, todos, and pipelines via the", + "1f2b347bd3": "通过", "8d90249d22": "gh auth login", - "2e44dda68a": "The GitHub CLI is installed but not authenticated. Run this command in a terminal:", + "2e44dda68a": "GitHub CLI 已安装但未通过身份验证。请在 terminal 中运行此命令:", "7755c28af5": "Install GitHub CLI", "23cb5a0dee": "Install the GitHub CLI to enable pull requests, issues, and checks.", - "6f30fc4216": "GitHub CLI status is not available in this runtime yet.", + "6f30fc4216": "此运行时暂不支持 GitHub CLI 状态。", "6b2cfb52b4": "gh", - "b4d900e7f1": "Pull requests, issues, and checks via the", - "account_scope_prefix": "Account scope" + "b4d900e7f1": "通过", + "account_scope_prefix": "账户范围" } } } @@ -8005,27 +8010,27 @@ "tracker": { "integration": { "cards": { - "c90f2ef419": "Re-check", + "c90f2ef419": "重新检查", "dd3529015d": "Disconnect {{value0}}", - "8b2408a8e5": "Jira is connected for this runtime. Re-check if the connected site list looks stale.", - "8c20e76308": "Each connected Jira site has one token stored by the active runtime.", - "c24e56c532": "Test", + "8b2408a8e5": "此运行时已连接 Jira。如果已连接的站点列表看起来过期,请重新检查。", + "8c20e76308": "每个已连接的 Jira 站点都由活动运行时存储一个 token。", + "c24e56c532": "测试", "3e7c10d286": "Testing...", - "a2c0015fb8": "Verified", + "a2c0015fb8": "已验证", "e2ff968276": "连接 Jira", - "60996beda6": "Add Jira site", - "7ca5ffffdb": "Browse, create, and start work from Jira Cloud issues.", - "a1093a06c7": "Checking Jira access before showing setup actions.", + "60996beda6": "添加 Jira 站点", + "7ca5ffffdb": "从 Jira Cloud 议题浏览、创建并开始工作。", + "a1093a06c7": "在显示设置操作前正在检查 Jira 访问权限。", "9fa04a032e": "{{value0}} site{{value1}} connected", - "cef18762a2": "Add access with a Personal API key from your Linear settings. Full-access keys can see every team the key owner can reach.", + "cef18762a2": "用 Linear 设置里的个人 API 密钥添加访问权限。完全访问密钥可看到密钥所有者能访问的所有团队。", "6224fe9d34": "每个已连接的 Linear 工作区都有一个由活动运行时存储的密钥。全权限密钥可覆盖密钥所有者可访问的所有团队;受限密钥可随时更换。", "1a12e33fe5": "添加 Linear 访问", - "622c224082": "Add workspace access", + "622c224082": "添加工作区访问权限", "eae4a9f16b": "添加 Linear 访问以浏览和链接议题。", - "fe9231215b": "Checking Linear access before showing setup actions.", + "fe9231215b": "在显示设置操作前正在检查 Linear 访问权限。", "e1f5e6424c": "{{value0}} workspace{{value1}} connected", "disconnect_all": "断开连接", - "account_scope_prefix": "Account scope" + "account_scope_prefix": "账户范围" } } } @@ -8035,55 +8040,55 @@ "control": { "integration": { "cards": { - "793a06e899": "Re-check", - "1a9475dace": "Learn more", - "19fb419c12": "Gitea credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.", - "60708f23da": "only when Orca cannot derive the API URL from the remote.", + "793a06e899": "重新检查", + "1a9475dace": "了解更多", + "19fb419c12": "Gitea 凭据已配置但无法通过身份验证。请检查 token、API 基础 URL 和仓库权限;如果环境变量已更改,请重启 Orca。", + "60708f23da": "仅当 Orca 无法从 remote 推导出 API URL 时。", "709057ad91": "ORCA_GITEA_API_BASE_URL", - "6da9dfa5de": "for private repositories, and set", + "6da9dfa5de": "用于私有仓库,并设置", "6d5c2a3005": "ORCA_GITEA_TOKEN", - "fcbe0469fd": "Public repositories are detected from their git remote. Set", - "0613928cb3": "Gitea status is not available in this runtime yet.", - "05863d2599": "Pull requests and commit statuses via the Gitea REST API.", - "52f75876be": "Pull requests and commit statuses for detected repositories", + "fcbe0469fd": "公开仓库会从其 git remote 检测。设置", + "0613928cb3": "此运行时暂不支持 Gitea 状态。", + "05863d2599": "通过 Gitea REST API 获取 pull request 和 commit 状态。", + "52f75876be": "已检测仓库的 pull request 和 commit 状态", "0b5242f8a2": "{{value0}} · Pull requests and commit statuses", - "40f678df73": "Azure DevOps credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.", - "7bd345e3f6": "only when Orca cannot derive the API base URL from the git remote.", + "40f678df73": "Azure DevOps 凭据已配置但无法通过身份验证。请检查 token、API 基础 URL 和仓库权限;如果环境变量已更改,请重启 Orca。", + "7bd345e3f6": "仅当 Orca 无法从 git remote 推导出 API 基础 URL 时。", "186a6689df": "ORCA_AZURE_DEVOPS_API_BASE_URL", - "b8a10b07c1": ". Set", + "b8a10b07c1": "。设置", "fbfd237f5e": "ORCA_AZURE_DEVOPS_ACCESS_TOKEN", - "087feb92f1": ", or set", + "087feb92f1": ",或设置", "48842720d2": "ORCA_AZURE_DEVOPS_TOKEN", - "7bbc9c64f0": "Set", - "f3f47dc7de": "Azure DevOps status is not available in this runtime yet.", - "0eb50d5593": "Pull requests and build statuses via Azure DevOps REST API tokens.", - "54636c65d4": "Pull requests and build statuses for detected Azure Repos", + "7bbc9c64f0": "设置", + "f3f47dc7de": "此运行时暂不支持 Azure DevOps 状态。", + "0eb50d5593": "通过 Azure DevOps REST API token 获取 pull request 和构建状态。", + "54636c65d4": "已检测 Azure Repos 的 pull request 和构建状态", "ea204f5e03": "{{value0}} · Pull requests and build statuses", - "6154b02093": "Bitbucket credentials are configured but could not authenticate. Check the token and repository permissions, then restart Orca if environment variables changed.", + "6154b02093": "Bitbucket 凭据已配置但无法通过身份验证。请检查 token 和仓库权限;如果环境变量已更改,请重启 Orca。", "e63fe8f627": "ORCA_BITBUCKET_ACCESS_TOKEN", "19416c874c": "ORCA_BITBUCKET_API_TOKEN", "fc71a0e7aa": "and", "63a7f47392": "ORCA_BITBUCKET_EMAIL", - "24ac1c69dc": "Bitbucket status is not available in this runtime yet.", - "a924e8dcd1": "Pull requests and build statuses via Bitbucket Cloud API tokens.", - "0fa5629dad": "Pull requests and build statuses" + "24ac1c69dc": "此运行时暂不支持 Bitbucket 状态。", + "a924e8dcd1": "通过 Bitbucket Cloud API token 获取 pull request 和构建状态。", + "0fa5629dad": "pull request 和构建状态" } } } } }, "computerUseSummary": { - "permissionsRequired": "{{value0}} permission{{value1}} required before agents can operate app windows.", - "checkingTitle": "Checking Computer Use access.", - "checkingDescription": "Orca is checking macOS privacy permissions for the Computer Use helper.", - "unavailableTitle": "Computer Use is unavailable.", + "permissionsRequired": "在 agent 可操作应用窗口前需要 {{value0}} 项权限{{value1}}。", + "checkingTitle": "正在检查计算机控制访问权限。", + "checkingDescription": "Orca 正在检查计算机控制助手的 macOS 隐私权限。", + "unavailableTitle": "计算机控制不可用。", "unavailableDescription": "Computer Use permissions are unavailable because {{value0}}.", - "readyTitle": "Computer Use is ready.", - "readyDescription": "Agents can inspect and operate app windows when you ask.", - "permissionsTitle": "Finish setup to use local apps." + "readyTitle": "计算机控制已就绪。", + "readyDescription": "当你要求时,Agent 可以检查并操作应用窗口。", + "permissionsTitle": "完成设置以使用本地应用。" }, "computerUseSkillRuntime": { - "thisDevice": "This device" + "thisDevice": "此设备" }, "WorkspaceDirectorySetting": { "1a2b3c4d5e": "客户端默认值", @@ -8095,28 +8100,28 @@ }, "ProviderHostScopeControl": { "scope_label": "{{value0}}: {{value1}}", - "change_host": "Open Remote Servers" + "change_host": "打开远程服务器" }, "providerAccountScope": { - "remoteServer": "Remote server: {{value0}}", - "remoteServerCredentials": "Credentials and account checks for this provider are owned by this remote server. Use Settings > Remote Orca Servers > Advanced to edit another default runtime scope.", - "localMac": "Local Mac", - "localCredentials": "Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.", - "remoteServerRateLimit": "{{value0}} API budget is fetched from the CLI on this remote server. Use Settings > Remote Orca Servers > Advanced to view another default runtime budget.", - "localRateLimit": "{{value0}} API budget is fetched from the CLI on this desktop client. Use Settings > Remote Orca Servers > Advanced to view server-owned budgets." + "remoteServer": "远程服务器:{{value0}}", + "remoteServerCredentials": "此提供商的凭据和账户检查由该远程服务器拥有。使用“设置 > 远程 Orca 服务器 > 高级”编辑另一个默认运行时范围。", + "localMac": "本地 Mac", + "localCredentials": "此提供商的凭据和账户检查由该桌面客户端拥有。使用“设置 > 远程 Orca 服务器 > 高级”编辑服务器拥有的凭据。", + "remoteServerRateLimit": "{{value0}} API 预算从此远程服务器上的 CLI 获取。使用“设置 > 远程 Orca 服务器 > 高级”查看另一个默认运行时预算。", + "localRateLimit": "{{value0}} API 预算从此桌面客户端上的 CLI 获取。使用“设置 > 远程 Orca 服务器 > 高级”查看服务器拥有的预算。" }, "settingOwnership": { - "clientDefault": "Client default", - "sourceControlAiDefaults": "Recipes, prompts, and hosted-review defaults are shared by this client; model choices and discovery stay scoped to the host where the agent runs.", - "projectOnThisHost": "Project on this host", - "repositorySourceControlAi": "These overrides apply to this project setup and inherit the client Source Control AI defaults until customized.", - "agentLaunchDefaults": "Default agent, command overrides, CLI arguments, and launch environment are client preferences. SSH and remote server launches still validate host availability at run time.", - "clientDefaultProjectScopes": "Client default + project scopes", - "terminalQuickCommands": "Commands are saved on this client, then scoped globally or to a project setup so they run from the selected terminal context.", - "hostOverride": "Host override", - "workspaceDirectory": "The client default is inherited until a host needs its own worktree directory.", - "providerHost": "Provider host", - "providerAccounts": "Credentials and account checks belong to the local client or selected remote server that owns the provider integration." + "clientDefault": "客户端默认", + "sourceControlAiDefaults": "方案、提示和托管评审默认值由此客户端共享;模型选择和发现则限定在 agent 运行的主机内。", + "projectOnThisHost": "此主机上的项目", + "repositorySourceControlAi": "这些覆盖项应用于此项目设置,并继承客户端的源代码管理 AI 默认值,直至自定义。", + "agentLaunchDefaults": "默认 agent、命令覆盖、CLI 参数和启动环境是客户端偏好。SSH 和远程服务器启动仍会在运行时验证主机可用性。", + "clientDefaultProjectScopes": "客户端默认 + 项目范围", + "terminalQuickCommands": "命令保存在此客户端,然后限定为全局或某项目设置,以便从所选 terminal 上下文运行。", + "hostOverride": "主机覆盖", + "workspaceDirectory": "在主机需要自己的工作树目录之前,继承客户端默认值。", + "providerHost": "提供商主机", + "providerAccounts": "凭据和账户检查归属于本地客户端或拥有该提供商集成的所选远程服务器。" }, "RepositoryForkSyncSection": { "defaultBranch": "默认分支", @@ -8146,38 +8151,38 @@ "off": "关闭" }, "DefaultWindowsProjectRuntimeSetting": { - "defaultRuntime": "Default project runtime", + "defaultRuntime": "默认项目运行时", "windows": "Windows", "wsl": "WSL", - "selectDistro": "Select distro", - "windowsDescription": "Projects inherit Windows unless a project overrides it.", - "wslUnavailable": "WSL is not available. Projects that inherit WSL will need repair.", - "distroRequired": "Choose a WSL distro before projects can inherit WSL.", - "wslDescription": "Projects inherit {{value0}} via WSL unless a project overrides it." + "selectDistro": "选择发行版", + "windowsDescription": "除非项目覆盖,否则项目继承 Windows。", + "wslUnavailable": "WSL 不可用。继承 WSL 的项目需要修复。", + "distroRequired": "在项目可继承 WSL 之前,请选择一个 WSL 发行版。", + "wslDescription": "除非项目覆盖,否则项目通过 WSL 继承 {{value0}}。" }, "ProjectWindowsRuntimeSetting": { - "projectRuntime": "Project runtime", + "projectRuntime": "项目运行时", "defaultRuntime": "Default ({{value0}})", "windows": "Windows", "wsl": "WSL", - "selectDistro": "Select distro", - "runtimeChangeHelp": "Runtime changes apply to new terminals, agent checks, and skill discovery for this project. Existing terminals keep their current runtime.", - "wslUnavailable": "WSL is not available. Switch this project to Windows or repair WSL.", - "distroMissing": "{{value0}} is not installed in WSL. Choose an installed distro or switch this project to Windows.", - "distroRequired": "Choose a WSL distro or switch this project to Windows.", - "inheritedWsl": "No project override. General settings select {{value0}} via WSL.", - "projectWsl": "This project runs in {{value0}} via WSL.", - "inheritedWindows": "No project override. General settings select Windows.", - "projectWindows": "This project runs on Windows.", + "selectDistro": "选择发行版", + "runtimeChangeHelp": "运行时更改应用于此项目的新 terminal、agent 检查和技能发现。已有 terminal 保留其当前运行时。", + "wslUnavailable": "WSL 不可用。请将此项目切换到 Windows 或修复 WSL。", + "distroMissing": "WSL 中未安装 {{value0}}。请选择已安装的发行版,或将此项目切换到 Windows。", + "distroRequired": "选择一个 WSL 发行版,或将此项目切换到 Windows。", + "inheritedWsl": "无项目覆盖。通用设置通过 WSL 选择 {{value0}}。", + "projectWsl": "此项目通过 WSL 运行在 {{value0}} 中。", + "inheritedWindows": "无项目覆盖。通用设置选择 Windows。", + "projectWindows": "此项目运行在 Windows 上。", "liveTerminalSingular": "{{count}} 个正在运行的终端", "liveTerminalPlural": "{{count}} 个正在运行的终端", "activeTaskSingular": "{{count}} 个活动任务", "activeTaskPlural": "{{count}} 个活动任务", "runtimeSessionJoin": "{{value0}}和{{value1}}", "runtimeSessionWarning": "{{value0}}会继续在当前运行时中运行。继续前请等待任务完成或重启终端。", - "pendingRuntimeChange": "Runtime change pending. New project work will use the selected runtime after you apply.", + "pendingRuntimeChange": "运行时更改待处理。应用后,新项目工作将使用所选运行时。", "cancel": "Cancel", - "applyRuntimeChange": "Apply runtime change" + "applyRuntimeChange": "应用运行时更改" }, "PrivacyDiagnosticsRows": { "5a7cbe069a": "已设置 DO_NOT_TRACK=1,创建和发送诊断文件已停用。", @@ -8187,34 +8192,34 @@ "e27c8d45bf": "诊断已被环境变量停用。" }, "ShortcutCommandBlock": { - "eb72c52c28": "Press a shortcut, or double-tap a modifier (e.g. {{value0}}). Esc cancels.", - "70b5d25583": "{{value0}} shortcut", - "287e07ddde": "Modified", - "3c83cd7d1c": "Disabled", - "07939d084e": "Reset {{value0}} to default", - "9b02917027": "Reset to default", - "a799f90f82": "Disable {{value0}}", - "25e6e76618": "Disable shortcut", - "035a822ef0": "Add shortcut", - "a0e2ef0e61": "Add another shortcut for {{value0}}", - "245c83af24": "Add another shortcut", - "482a60225d": "Enable {{value0}}", - "6287677c37": "Enable", - "01481b964c": "Add shortcut for {{value0}}" + "eb72c52c28": "按下快捷键,或双击修饰键(例如 {{value0}})。Esc 取消。", + "70b5d25583": "{{value0}} 快捷键", + "287e07ddde": "已修改", + "3c83cd7d1c": "已禁用", + "07939d084e": "将 {{value0}} 重置为默认", + "9b02917027": "重置为默认", + "a799f90f82": "禁用 {{value0}}", + "25e6e76618": "禁用快捷键", + "035a822ef0": "添加快捷键", + "a0e2ef0e61": "为 {{value0}} 添加另一个快捷键", + "245c83af24": "添加另一个快捷键", + "482a60225d": "启用 {{value0}}", + "6287677c37": "启用", + "01481b964c": "为 {{value0}} 添加快捷键" }, "ShortcutRecorderButton": { - "1a13bb054d": "Press shortcut keys for {{value0}}. Escape cancels.", - "3732775d74": "Add shortcut for {{value0}}", - "88764af2c1": "Change shortcut for {{value0}}", - "30feb099d6": "Change shortcut {{value0}} of {{value1}} for {{value2}}", - "5d982a2a1f": "Listening for shortcut", - "152e0bcd64": "Add shortcut", - "5bd56445da": "Change shortcut", - "f5ed5dcbf6": "Press keys…" + "1a13bb054d": "为 {{value0}} 按下快捷键。Esc 取消。", + "3732775d74": "为 {{value0}} 添加快捷键", + "88764af2c1": "更改 {{value0}} 的快捷键", + "30feb099d6": "更改 {{value2}} 的第 {{value0}}/{{value1}} 个快捷键", + "5d982a2a1f": "正在录制快捷键", + "152e0bcd64": "添加快捷键", + "5bd56445da": "更改快捷键", + "f5ed5dcbf6": "按下按键…" }, "ShortcutRemoveButton": { "9e29aff18b": "Remove {{value0}} shortcut {{value1}}", - "2a9588b1c2": "Remove this binding" + "2a9588b1c2": "移除此绑定" } }, "right": { @@ -8893,7 +8898,7 @@ "542bf6a7e2": "斜体", "256300f8ea": "粗体", "87aff03d63": "正在发送...", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "评论过长,无法安全提交。" } } } @@ -9672,7 +9677,7 @@ "14e144f7a7": "GitLab API预算", "3e2c982cfa": "向左,重置于", "ea8ad0bae8": "的", - "0a891e8935": "休息API", + "0a891e8935": "REST API", "953f7c6062": "此 GitLab 主机未返回速率限制标头。", "budget_scope_prefix": "Budget scope" } @@ -10422,7 +10427,7 @@ }, "EditorContent": { "56dba34e1a": "(在源模式下编辑)", - "e4b074749d": "前线事项", + "e4b074749d": "前言", "9640d1d3db": "预览此差异的修改版本。切换到源模式以检查更改。", "78541e254e": "二进制文件已更改", "c88c73a0d3": "加载差异...", @@ -10522,7 +10527,7 @@ "d737791433": "为AI添加注释", "b1bfc04034": "选定的文本", "f37b98999e": "此注", - "2b2b31382c": "前线事项", + "2b2b31382c": "前言", "bb629de58a": "复制备注给 Agent", "322afab6ff": "复习笔记", "0f9969a159": "跳转到第一个评论笔记", @@ -10869,7 +10874,7 @@ "2b3ce6d394": "取消", "e05063cfc1": "行 {{value0}}", "c845170b3b": "行 {{value0}}-{{value1}}", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "评论过长,无法安全提交。" }, "useDiffCommentDecorator": { "995fa28b50": "此注" From c1e071870ba73e7db854fb344ff9ca99d3013254 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 21 Jun 2026 21:58:15 -0700 Subject: [PATCH 008/282] Dispose worktree change refresh queues (#6040) Co-authored-by: Neil --- src/renderer/src/hooks/useIpcEvents.ts | 1 + .../worktree-change-refresh-queue.test.ts | 26 +++++++++++++++++++ .../hooks/worktree-change-refresh-queue.ts | 14 ++++++++-- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 34f96e34f93..94977d77b7f 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -810,6 +810,7 @@ export function useIpcEvents(): void { } } const worktreeChangeRefreshQueue = createWorktreeChangeRefreshQueue(handleWorktreesChanged) + unsubs.push(worktreeChangeRefreshQueue.dispose) const activateNotifiedWorktree = async ( { diff --git a/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts b/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts index 82ce23a3ecd..be61b8dc3b0 100644 --- a/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts +++ b/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts @@ -139,4 +139,30 @@ describe('createWorktreeChangeRefreshQueue', () => { expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', renamed) expect(handler).toHaveBeenNthCalledWith(3, 'repo-1', undefined) }) + + it('drops queued trailing refreshes after disposal', async () => { + const firstRefresh = deferred() + const handler = vi.fn().mockReturnValueOnce(firstRefresh.promise).mockResolvedValue(undefined) + const queue = createWorktreeChangeRefreshQueue(handler) + + queue.enqueue({ repoId: 'repo-1' }) + queue.enqueue({ repoId: 'repo-1' }) + queue.dispose() + + firstRefresh.resolve() + await flushPromises() + + expect(handler).toHaveBeenCalledTimes(1) + }) + + it('ignores new events after disposal', async () => { + const handler = vi.fn(() => Promise.resolve()) + const queue = createWorktreeChangeRefreshQueue(handler) + + queue.dispose() + queue.enqueue({ repoId: 'repo-1' }) + await flushPromises() + + expect(handler).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/hooks/worktree-change-refresh-queue.ts b/src/renderer/src/hooks/worktree-change-refresh-queue.ts index f636a5441cd..ed095d2a98a 100644 --- a/src/renderer/src/hooks/worktree-change-refresh-queue.ts +++ b/src/renderer/src/hooks/worktree-change-refresh-queue.ts @@ -20,6 +20,7 @@ type RepoRefreshState = { } export type WorktreeChangeRefreshQueue = { + dispose: () => void enqueue: (event: WorktreeChangeEvent) => void } @@ -27,11 +28,12 @@ export function createWorktreeChangeRefreshQueue( handler: WorktreeChangeRefreshHandler ): WorktreeChangeRefreshQueue { const states = new Map() + let disposed = false const drain = async (repoId: string, state: RepoRefreshState): Promise => { state.running = true try { - while (state.queue.length > 0) { + while (!disposed && state.queue.length > 0) { const next = state.queue.shift() try { await handler(repoId, next?.renamed) @@ -41,7 +43,7 @@ export function createWorktreeChangeRefreshQueue( } } finally { state.running = false - if (state.queue.length === 0) { + if (disposed || state.queue.length === 0) { states.delete(repoId) } else { void drain(repoId, state) @@ -50,7 +52,15 @@ export function createWorktreeChangeRefreshQueue( } return { + dispose() { + disposed = true + states.clear() + }, + enqueue(event) { + if (disposed) { + return + } let state = states.get(event.repoId) if (!state) { state = { running: false, queue: [] } From fc254deef5b8f49295a46854b57c783801c84893 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:35:48 -0700 Subject: [PATCH 009/282] Default new worktree card style on for fresh profiles (#6047) Enable `experimentalNewWorktreeCardStyle` by default on new profiles where onboarding is open, or when creating a brand-new config file. - Avoids visual disruption for existing users who already completed onboarding by keeping the old style active for them. - Preserves explicit user opt-outs during migrations and CLI updates. - Ensures the offline CLI handler mirrors this logic when creating the initial configuration file before the desktop client loads. --- src/cli/handlers/agent-hooks.test.ts | 111 +++++++++++++++++++++++++++ src/cli/handlers/agent-hooks.ts | 16 +++- src/main/persistence.test.ts | 54 +++++++++++++ src/main/persistence.ts | 30 +++++++- 4 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 src/cli/handlers/agent-hooks.test.ts diff --git a/src/cli/handlers/agent-hooks.test.ts b/src/cli/handlers/agent-hooks.test.ts new file mode 100644 index 00000000000..7e0638a07d9 --- /dev/null +++ b/src/cli/handlers/agent-hooks.test.ts @@ -0,0 +1,111 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../shared/constants' +import type { PersistedState } from '../../shared/types' +import type { HandlerContext } from '../dispatch' + +const { + applyAgentStatusHooksEnabledMock, + getDefaultUserDataPathMock, + getManagedAgentHookStatusesMock +} = vi.hoisted(() => ({ + applyAgentStatusHooksEnabledMock: vi.fn(), + getDefaultUserDataPathMock: vi.fn(), + getManagedAgentHookStatusesMock: vi.fn() +})) + +vi.mock('../runtime-client', () => { + class RuntimeClientError extends Error { + readonly code: string + + constructor(code: string, message: string) { + super(message) + this.code = code + } + } + + return { + RuntimeClientError, + getDefaultUserDataPath: getDefaultUserDataPathMock + } +}) + +vi.mock('../../main/agent-hooks/managed-agent-hook-controls', () => ({ + applyAgentStatusHooksEnabled: applyAgentStatusHooksEnabledMock, + getManagedAgentHookStatuses: getManagedAgentHookStatusesMock +})) + +import { AGENT_HOOK_HANDLERS } from './agent-hooks' + +function readDataFile(userDataPath: string): PersistedState { + return JSON.parse(readFileSync(join(userDataPath, 'orca-data.json'), 'utf-8')) as PersistedState +} + +function writeDataFile(userDataPath: string, state: PersistedState): void { + mkdirSync(userDataPath, { recursive: true }) + writeFileSync(join(userDataPath, 'orca-data.json'), JSON.stringify(state, null, 2), 'utf-8') +} + +function createOfflineClient() { + return { + getCliStatus: vi.fn().mockResolvedValue({ result: { runtime: { reachable: false } } }), + call: vi.fn() + } +} + +async function runAgentHooksOff(userDataPath: string): Promise { + getDefaultUserDataPathMock.mockReturnValue(userDataPath) + await AGENT_HOOK_HANDLERS['agent hooks off']({ + flags: new Map(), + client: createOfflineClient() as HandlerContext['client'], + cwd: userDataPath, + json: true + }) +} + +describe('agent hooks CLI handler', () => { + let userDataPath: string + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-agent-hooks-cli-')) + applyAgentStatusHooksEnabledMock.mockReturnValue([]) + getManagedAgentHookStatusesMock.mockReturnValue([]) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('keeps the fresh-profile new card style default when creating offline settings', async () => { + await runAgentHooksOff(userDataPath) + + const persisted = readDataFile(userDataPath) + + expect(persisted.settings.experimentalNewWorktreeCardStyle).toBe(true) + expect(persisted.settings.agentStatusHooksEnabled).toBe(false) + }) + + it('defaults missing new card style on while offline-updated onboarding is open', async () => { + const existing = getDefaultPersistedState(userDataPath) + delete existing.settings.experimentalNewWorktreeCardStyle + writeDataFile(userDataPath, existing) + + await runAgentHooksOff(userDataPath) + + expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(true) + }) + + it('preserves an existing explicit new card style opt-out when updating offline settings', async () => { + const existing = getDefaultPersistedState(userDataPath) + existing.settings.experimentalNewWorktreeCardStyle = false + writeDataFile(userDataPath, existing) + + await runAgentHooksOff(userDataPath) + + expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(false) + }) +}) diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index fdd6b3114f1..e5a29585274 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -31,7 +31,16 @@ function isRecord(value: unknown): value is Record { function readPersistedState(dataPath: string): PersistedState { if (!existsSync(dataPath)) { - return getDefaultPersistedState(homedir()) + const defaults = getDefaultPersistedState(homedir()) + return { + ...defaults, + settings: { + ...defaults.settings, + // Why: offline CLI can create the first profile before desktop load; + // match the Store fresh-install default instead of pinning old cards. + experimentalNewWorktreeCardStyle: true + } + } } try { const parsed = JSON.parse(readFileSync(dataPath, 'utf-8')) @@ -74,9 +83,14 @@ function readEnabledFromDisk(): boolean { function updateEnabledOnDisk(enabled: boolean): string { const dataPath = getDataPath() const state = readPersistedState(dataPath) + const experimentalNewWorktreeCardStyle = + state.settings?.experimentalNewWorktreeCardStyle ?? state.onboarding?.closedAt === null state.settings = { ...getDefaultPersistedState(homedir()).settings, ...state.settings, + // Why: offline CLI can run before Store.load(); mirror its open-onboarding + // default without overriding a saved user opt-out. + experimentalNewWorktreeCardStyle, agentStatusHooksEnabled: enabled } writePersistedState(dataPath, state) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 9523d407f26..fc67b98f78d 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -489,6 +489,7 @@ describe('Store', () => { expect(settings.experimentalActivity).toBe(false) expect(settings.experimentalActivityDefaultedOffForAllUsers).toBe(true) expect(settings.experimentalTerminalAttention).toBe(false) + expect(settings.experimentalNewWorktreeCardStyle).toBe(true) expect(settings.floatingTerminalEnabled).toBe(true) expect(settings.floatingTerminalDefaultedForAllUsers).toBe(true) expect(settings.notifications.customSoundPath).toBeNull() @@ -619,6 +620,57 @@ describe('Store', () => { expect(store.getUI().setupGuideSidebarDismissed).toBe(false) }) + it('defaults new worktree card style on while onboarding is open', async () => { + writeDataFile({ + settings: {}, + onboarding: { + flowVersion: ONBOARDING_FLOW_VERSION, + closedAt: null, + outcome: null, + lastCompletedStep: -1, + checklist: {} + }, + ui: {} + }) + + const store = await createStore() + + expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(true) + }) + + it('preserves explicit new worktree card style opt-out while onboarding is open', async () => { + writeDataFile({ + settings: { + experimentalNewWorktreeCardStyle: false + }, + onboarding: { + flowVersion: ONBOARDING_FLOW_VERSION, + closedAt: null, + outcome: null, + lastCompletedStep: -1, + checklist: {} + }, + ui: {} + }) + + const store = await createStore() + + expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(false) + }) + + it('keeps new worktree card style off for existing users backfilled as completed', async () => { + writeDataFile({ + schemaVersion: 1, + settings: {}, + ui: {} + }) + + const store = await createStore() + + expect(store.getOnboarding().closedAt).not.toBeNull() + expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(false) + }) + it('treats persisted false setup guide sidebar dismissal as stale once onboarding is closed', async () => { writeDataFile({ onboarding: { @@ -1668,6 +1720,7 @@ describe('Store', () => { const store = await createStore() expect(store.getRepos()).toEqual([]) expect(store.getSettings().theme).toBe('system') + expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(false) }) // ── 4. Schema migration: merges with defaults ─────────────────────── @@ -8169,6 +8222,7 @@ describe('Store', () => { expect(t!.installId).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ ) + expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(false) }) it('preserves an already-migrated telemetry block on subsequent launches', async () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index ac952c3208c..6163cd9749e 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -1048,6 +1048,10 @@ function resolveSetupGuideSidebarDismissedOnLoad( return onboarding.closedAt !== null || persistedDismissed === true } +function shouldDefaultNewWorktreeCardStyleOn(onboarding: OnboardingState): boolean { + return onboarding.closedAt === null +} + // Why: read a settings field that was removed from GlobalSettings but can // still exist on disk. One-shot use for the inline-agents migration. function readDeprecatedExperimentFlag(parsed: PersistedState | undefined): boolean { @@ -2653,6 +2657,16 @@ export class Store { if (!parsed.onboarding) { this.loadNeedsSave = true } + const defaultNewWorktreeCardStyle = + shouldDefaultNewWorktreeCardStyleOn(normalizedOnboarding) + const migratedExperimentalNewWorktreeCardStyle = + parsed.settings?.experimentalNewWorktreeCardStyle ?? defaultNewWorktreeCardStyle + if ( + parsed.settings?.experimentalNewWorktreeCardStyle === undefined && + defaultNewWorktreeCardStyle + ) { + this.loadNeedsSave = true + } const normalizedProjectGroups = normalizeProjectGroups(parsed.projectGroups) const loadedCompactWorktreeCards = parsed.settings?.compactWorktreeCards ?? @@ -2705,6 +2719,9 @@ export class Store { ...migratedTerminalCursorStyle, experimentalActivity: migratedExperimentalActivity, experimentalActivityDefaultedOffForAllUsers: true, + // Why: open first-run onboarding is the local fresh-install signal; + // closed/backfilled onboarding identifies existing profiles. + experimentalNewWorktreeCardStyle: migratedExperimentalNewWorktreeCardStyle, // Why: compact worktree cards graduated from Experimental; preserve // the old opt-in for profiles written during the rollout. compactWorktreeCards: loadedCompactWorktreeCards, @@ -2977,7 +2994,18 @@ export class Store { } if (result === null) { - result = getDefaultPersistedState(homedir()) + const defaults = getDefaultPersistedState(homedir()) + const isFreshDefaultProfile = + !fileExistedOnLoad && shouldDefaultNewWorktreeCardStyleOn(defaults.onboarding) + result = { + ...defaults, + settings: { + ...defaults.settings, + // Why: a corrupt existing data file also falls back to defaults; only + // the absent-file path is a true fresh install. + experimentalNewWorktreeCardStyle: isFreshDefaultProfile + } + } } const workspaceSession = pruneWorkspaceSessionBrowserHistory( From 88da1808f73bade5e4010cd24843d794846044dd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:39:09 -0700 Subject: [PATCH 010/282] Prevent stale sleeping agents from resuming (#6018) Co-authored-by: Orca --- .../src/lib/agent-hibernation-planner.test.ts | 4 + .../src/lib/agent-hibernation-planner.ts | 6 +- .../lib/resume-sleeping-agent-session.test.ts | 89 ++++++++ .../src/lib/resume-sleeping-agent-session.ts | 17 ++ .../agent-status-manual-sleep-capture.test.ts | 192 ++++++++++++++++++ src/renderer/src/store/slices/agent-status.ts | 105 +++++++++- .../src/store/slices/store-cascades.test.ts | 28 ++- src/renderer/src/store/slices/terminals.ts | 34 +++- src/shared/agent-session-resume.ts | 1 + src/shared/workspace-session-schema.test.ts | 30 +++ .../workspace-session-sleeping-agents.ts | 1 + 11 files changed, 480 insertions(+), 27 deletions(-) create mode 100644 src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts diff --git a/src/renderer/src/lib/agent-hibernation-planner.test.ts b/src/renderer/src/lib/agent-hibernation-planner.test.ts index 73a227fff72..1c629053474 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.test.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.test.ts @@ -109,6 +109,10 @@ describe('agent sleep planner', () => { const e = entry({ state }) expect(plannedWorktrees(snapshot({ agentStatusByPaneKey: { [e.paneKey]: e } }))).toEqual([]) } + const interrupted = entry({ interrupted: true }) + expect( + plannedWorktrees(snapshot({ agentStatusByPaneKey: { [interrupted.paneKey]: interrupted } })) + ).toEqual([]) const noSession = entry({ providerSession: undefined }) expect( plannedWorktrees(snapshot({ agentStatusByPaneKey: { [noSession.paneKey]: noSession } })) diff --git a/src/renderer/src/lib/agent-hibernation-planner.ts b/src/renderer/src/lib/agent-hibernation-planner.ts index 1f9560d10e2..43f41bd5729 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.ts @@ -133,7 +133,11 @@ function getEligiblePane(args: { lastTerminalInputAtByPaneKey, mobileLockedPtyIds } = args - if (entry.state !== 'done' || sleepingAgentSessionsByPaneKey[entry.paneKey]) { + if ( + entry.state !== 'done' || + entry.interrupted === true || + sleepingAgentSessionsByPaneKey[entry.paneKey] + ) { return null } if ( diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts index 61a30d222d6..969339c816e 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' import { makePaneKey } from '../../../shared/stable-pane-id' +import { parseWorkspaceSession } from '../../../shared/workspace-session-schema' import { useAppStore } from '@/store' import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' @@ -322,6 +323,94 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() }) + it('resumes intentional completed worktree-sleep records', () => { + const record = makeRecord({ origin: 'worktree-sleep', state: 'done' }) + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1') + + expect(launched).toBe(1) + expect(useAppStore.getState().tabsByWorktree['wt-1']?.[0]?.launchAgent).toBe('claude') + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + }) + + it('clears stale manual records without launching a tab', () => { + const record = makeRecord({ capturedAt: 3_000_000, updatedAt: 1 }) + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1') + + expect(launched).toBe(0) + expect(useAppStore.getState().tabsByWorktree['wt-1']).toEqual([]) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + }) + + it('clears interrupted manual records without launching a tab', () => { + const record = makeRecord({ origin: 'worktree-sleep', interrupted: true }) + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1') + + expect(launched).toBe(0) + expect(useAppStore.getState().tabsByWorktree['wt-1']).toEqual([]) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + }) + + it('clears hydrated interrupted worktree-sleep records without launching a tab', () => { + const parsed = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab-1:leaf-1': makeRecord({ + state: 'done', + origin: 'worktree-sleep', + interrupted: true + }) + } + }) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + throw new Error(parsed.error) + } + const record = parsed.value.sleepingAgentSessionsByPaneKey!['tab-1:leaf-1']! + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1') + + expect(launched).toBe(0) + expect(useAppStore.getState().tabsByWorktree['wt-1']).toEqual([]) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + }) + + it('clears legacy completed live records without launching a tab', () => { + const record = makeRecord({ state: 'done' }) + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1') + + expect(launched).toBe(0) + expect(useAppStore.getState().tabsByWorktree['wt-1']).toEqual([]) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + }) + it('uses WSL resume quoting for Windows-path projects forced to WSL', () => { const record = makeRecord({ providerSession: { key: 'session_id', id: "sess-1's" }, diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index 89fba226855..c136b01e263 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -14,6 +14,7 @@ import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-r import type { TerminalTab } from '../../../shared/types' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import { translate } from '@/i18n/i18n' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../shared/agent-status-types' function getResumeLaunchPlatform(worktreeId: string): NodeJS.Platform { const state = useAppStore.getState() @@ -187,6 +188,18 @@ function recordPaneIsOwnedByPreservedPane( ) } +function isInvalidWorktreeActivationRecord(record: SleepingAgentSessionRecord): boolean { + if (record.interrupted === true) { + return true + } + if (!record.origin && record.state === 'done') { + return true + } + return ( + record.state !== 'done' && record.capturedAt - record.updatedAt > AGENT_STATUS_STALE_AFTER_MS + ) +} + export function resumeSleepingAgentSessionsForWorktree(worktreeId: string): number { const state = useAppStore.getState() const worktreeRecords = Object.values(state.sleepingAgentSessionsByPaneKey) @@ -207,6 +220,10 @@ export function resumeSleepingAgentSessionsForWorktree(worktreeId: string): numb let launched = 0 for (const record of records) { const claimKey = getProviderSessionClaimKey(record) + if (isInvalidWorktreeActivationRecord(record)) { + state.clearSleepingAgentSession(record.paneKey) + continue + } if (paneOwnedClaimKeys.has(claimKey)) { if (!recordPaneIsOwnedByPreservedPane(record, state)) { state.clearSleepingAgentSession(record.paneKey) diff --git a/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts b/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts new file mode 100644 index 00000000000..d5226e11953 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { type SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import type { AppState } from '../types' +import { createTestStore, makeTab } from './store-test-helpers' + +const NOW = 1_800_000_000_000 + +afterEach(() => { + vi.useRealTimers() +}) + +function makeAgentEntry(overrides: Partial = {}): AgentStatusEntry { + const paneKey = overrides.paneKey ?? 'tab-1:leaf-1' + return { + state: 'working', + prompt: 'finish the task', + updatedAt: NOW, + stateStartedAt: NOW, + stateHistory: [], + agentType: 'codex', + paneKey, + tabId: paneKey.split(':')[0], + worktreeId: 'wt-1', + providerSession: { key: 'session_id', id: `session-${paneKey}` }, + ...overrides + } +} + +function seedTabs(store: ReturnType): void { + store.setState({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] + } + } as Partial) +} + +function makeSleepingRecord( + overrides: Partial = {} +): SleepingAgentSessionRecord { + const paneKey = overrides.paneKey ?? 'tab-1:leaf-1' + return { + paneKey, + tabId: paneKey.split(':')[0], + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: `sleeping-${paneKey}` }, + prompt: 'old prompt', + state: 'working', + capturedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1, + updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1, + origin: 'live', + ...overrides + } +} + +describe('manual sleep agent session capture', () => { + it('captures only fresh active live rows as worktree-sleep records', () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const store = createTestStore() + seedTabs(store) + store.setState({ + agentStatusByPaneKey: { + 'tab-1:fresh': makeAgentEntry({ paneKey: 'tab-1:fresh' }), + 'tab-1:stale': makeAgentEntry({ + paneKey: 'tab-1:stale', + updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1 + }), + 'tab-1:done': makeAgentEntry({ paneKey: 'tab-1:done', state: 'done' }), + 'tab-1:interrupted': makeAgentEntry({ + paneKey: 'tab-1:interrupted', + state: 'done', + interrupted: true + }), + 'tab-1:post-input': makeAgentEntry({ + paneKey: 'tab-1:post-input', + updatedAt: NOW - 1_000 + }) + }, + lastTerminalInputAtByPaneKey: { 'tab-1:post-input': NOW } + } as Partial) + + store.getState().captureSleepingAgentSessionsByWorktree('wt-1') + + const records = store.getState().sleepingAgentSessionsByPaneKey + expect(Object.keys(records).sort()).toEqual(['tab-1:fresh']) + expect(records['tab-1:fresh']).toMatchObject({ + origin: 'worktree-sleep', + state: 'working', + providerSession: { key: 'session_id', id: 'session-tab-1:fresh' } + }) + }) + + it('preserves retained completed sessions as intentional sleep records', () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const store = createTestStore() + seedTabs(store) + const entry = makeAgentEntry({ paneKey: 'tab-1:done', state: 'done' }) + const tab = makeTab({ id: 'tab-1', worktreeId: 'wt-1' }) + store.setState({ + retainedAgentsByPaneKey: { + 'tab-1:done': { + entry, + tab, + worktreeId: 'wt-1', + agentType: 'codex', + startedAt: entry.stateStartedAt + } + } + } as Partial) + + store.getState().captureSleepingAgentSessionsByWorktree('wt-1') + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:done']).toMatchObject({ + origin: 'worktree-sleep', + state: 'done', + providerSession: { key: 'session_id', id: 'session-tab-1:done' } + }) + }) + + it('clears pre-existing records for rows skipped by manual capture', () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const store = createTestStore() + seedTabs(store) + store.setState({ + agentStatusByPaneKey: { + 'tab-1:stale': makeAgentEntry({ + paneKey: 'tab-1:stale', + updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1 + }) + }, + sleepingAgentSessionsByPaneKey: { + 'tab-1:stale': makeSleepingRecord({ paneKey: 'tab-1:stale' }) + } + } as Partial) + + store.getState().captureSleepingAgentSessionsByWorktree('wt-1') + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:stale']).toBeUndefined() + }) + + it('uses manual sleep filtering when terminal shutdown captures sleeping records', async () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const store = createTestStore() + seedTabs(store) + store.setState({ + agentStatusByPaneKey: { + 'tab-1:fresh': makeAgentEntry({ paneKey: 'tab-1:fresh' }), + 'tab-1:done': makeAgentEntry({ paneKey: 'tab-1:done', state: 'done' }) + } + } as Partial) + + await store.getState().shutdownWorktreeTerminals('wt-1', { keepIdentifiers: true }) + + const records = store.getState().sleepingAgentSessionsByPaneKey + expect(Object.keys(records)).toEqual(['tab-1:fresh']) + expect(records['tab-1:fresh']).toMatchObject({ + origin: 'worktree-sleep', + state: 'working' + }) + }) + + it('clears pre-existing records for rows skipped during terminal shutdown capture', async () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const store = createTestStore() + seedTabs(store) + store.setState({ + ptyIdsByTabId: { 'tab-1': [] }, + agentStatusByPaneKey: { + 'tab-1:stale': makeAgentEntry({ + paneKey: 'tab-1:stale', + updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1 + }) + }, + sleepingAgentSessionsByPaneKey: { + 'tab-1:stale': makeSleepingRecord({ paneKey: 'tab-1:stale' }) + } + } as Partial) + + await store.getState().shutdownWorktreeTerminals('wt-1', { keepIdentifiers: true }) + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:stale']).toBeUndefined() + }) +}) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 59e802aedce..9103f548f28 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -344,21 +344,94 @@ function sleepingRecordFromEntry(args: { ? { lastAssistantMessage: args.entry.lastAssistantMessage } : {}), ...(args.launchConfig ? { launchConfig: copyLaunchConfig(args.launchConfig) } : {}), + ...(args.entry.interrupted ? { interrupted: true } : {}), ...(args.origin ? { origin: args.origin } : {}) } } +type CollectSleepingAgentSessionRecordsOptions = { + paneKeys?: readonly string[] + captureMode?: 'manual-worktree-sleep' | 'completed-agent-hibernation' +} + +function normalizeSleepingAgentSessionCollectOptions( + options: readonly string[] | CollectSleepingAgentSessionRecordsOptions | undefined +): CollectSleepingAgentSessionRecordsOptions { + if (!options) { + return {} + } + return Array.isArray(options) + ? { paneKeys: options } + : (options as CollectSleepingAgentSessionRecordsOptions) +} + +function isValidManualSleepLiveAgentEntry( + state: AppState, + entry: AgentStatusEntry, + capturedAt: number +): boolean { + if (entry.interrupted === true || entry.state === 'done') { + return false + } + const lastInputAt = state.lastTerminalInputAtByPaneKey[entry.paneKey] + if ( + typeof lastInputAt === 'number' && + Number.isFinite(lastInputAt) && + lastInputAt > entry.updatedAt + ) { + return false + } + return isExplicitAgentStatusFresh(entry, capturedAt, AGENT_STATUS_STALE_AFTER_MS) +} + +function isValidCompletedAgentHibernationEntry(entry: AgentStatusEntry): boolean { + return entry.state === 'done' && entry.interrupted !== true +} + +export function removeSleepingRecordsReplacedByManualWorktreeSleep( + records: Record, + worktreeId: string, + paneKeys?: readonly string[] +): { records: Record; changed: boolean } { + const allowedPaneKeys = paneKeys ? new Set(paneKeys) : null + let next = records + let changed = false + for (const [paneKey, record] of Object.entries(records)) { + if (record.worktreeId !== worktreeId || (allowedPaneKeys && !allowedPaneKeys.has(paneKey))) { + continue + } + if (next === records) { + next = { ...records } + } + delete next[paneKey] + changed = true + } + return { records: next, changed } +} + export function collectSleepingAgentSessionRecordsForWorktree( state: AppState, worktreeId: string, - paneKeys?: string[] + options?: readonly string[] | CollectSleepingAgentSessionRecordsOptions ): Record { const capturedAt = Date.now() - const allowedPaneKeys = paneKeys ? new Set(paneKeys) : null + const collectOptions = normalizeSleepingAgentSessionCollectOptions(options) + const allowedPaneKeys = collectOptions.paneKeys ? new Set(collectOptions.paneKeys) : null + const isManualWorktreeSleep = collectOptions.captureMode === 'manual-worktree-sleep' + const isCompletedAgentHibernation = collectOptions.captureMode === 'completed-agent-hibernation' + const isWorktreeOwnedCapture = isManualWorktreeSleep || isCompletedAgentHibernation + // Why: hibernated completions are intentional worktree-owned records; wake + // treats originless completed records as ambiguous legacy captures. + const origin: SleepingAgentSessionRecord['origin'] | undefined = isWorktreeOwnedCapture + ? 'worktree-sleep' + : undefined const tabPrefixes = (state.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) const records: Record = {} for (const retained of Object.values(state.retainedAgentsByPaneKey)) { + if (isCompletedAgentHibernation) { + continue + } if (allowedPaneKeys && !allowedPaneKeys.has(retained.entry.paneKey)) { continue } @@ -371,7 +444,8 @@ export function collectSleepingAgentSessionRecordsForWorktree( worktreeId, tab: retained.tab, capturedAt, - launchConfig: getLaunchConfigForEntry(state, retained.entry) + launchConfig: getLaunchConfigForEntry(state, retained.entry), + origin }) if (record) { records[record.paneKey] = record @@ -387,12 +461,19 @@ export function collectSleepingAgentSessionRecordsForWorktree( if (!belongsToWorktree) { continue } + if (isManualWorktreeSleep && !isValidManualSleepLiveAgentEntry(state, entry, capturedAt)) { + continue + } + if (isCompletedAgentHibernation && !isValidCompletedAgentHibernationEntry(entry)) { + continue + } const record = sleepingRecordFromEntry({ state, entry, worktreeId, capturedAt, - launchConfig: getLaunchConfigForEntry(state, entry) + launchConfig: getLaunchConfigForEntry(state, entry), + origin }) if (record) { records[record.paneKey] = record @@ -1787,11 +1868,17 @@ export const createAgentStatusSlice: StateCreator { set((s) => { - const records = collectSleepingAgentSessionRecordsForWorktree(s, worktreeId, paneKeys) - const next: Record = { - ...s.sleepingAgentSessionsByPaneKey - } - let changed = false + const records = collectSleepingAgentSessionRecordsForWorktree(s, worktreeId, { + paneKeys, + captureMode: 'manual-worktree-sleep' + }) + const replaced = removeSleepingRecordsReplacedByManualWorktreeSleep( + s.sleepingAgentSessionsByPaneKey, + worktreeId, + paneKeys + ) + const next: Record = { ...replaced.records } + let changed = replaced.changed for (const record of Object.values(records)) { if (next[record.paneKey] !== record) { diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 663b4df976d..032fa0d81e5 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -2697,6 +2697,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { [siblingLeaf]: 'pty-shell' }) expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toMatchObject({ + origin: 'worktree-sleep', providerSession: { key: 'session_id', id: 'target-session' } }) expect(state.sleepingAgentSessionsByPaneKey[siblingPaneKey]).toBe(siblingSleepingRecordBefore) @@ -3218,6 +3219,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { it('commits sleep state after exact runtime stop for runtime-backed PTYs', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' + const now = Date.now() mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => Promise.resolve( createCompatibleRuntimeStatusResponseIfNeeded(args) ?? { @@ -3245,12 +3247,12 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { store.getState().setAgentStatus( 'tab-1:live', { - state: 'done', + state: 'working', prompt: 'resume live', agentType: 'codex' }, 'Codex', - { updatedAt: 1000, stateStartedAt: 1000 }, + { updatedAt: now, stateStartedAt: now }, { tabId: 'tab-1', worktreeId: wt }, { providerSession: { key: 'session_id', id: 'live-session' } } ) @@ -3269,6 +3271,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { }) ) expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({ + origin: 'worktree-sleep', providerSession: { key: 'session_id', id: 'live-session' } }) expect(store.getState().agentStatusByPaneKey['tab-1:live']).toBeUndefined() @@ -3532,6 +3535,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { it('commits the pre-stop sleeping record when exact-stop exit clears live status', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' + const now = Date.now() mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => { if (args.method === 'terminal.stopExact') { store.getState().removeAgentStatus('tab-1:live') @@ -3565,12 +3569,12 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { store.getState().setAgentStatus( 'tab-1:live', { - state: 'done', + state: 'working', prompt: 'resume live', agentType: 'codex' }, 'Codex', - { updatedAt: 1000, stateStartedAt: 1000 }, + { updatedAt: now, stateStartedAt: now }, { tabId: 'tab-1', worktreeId: wt }, { providerSession: { key: 'session_id', id: 'live-session' } } ) @@ -3582,6 +3586,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { }) expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({ + origin: 'worktree-sleep', providerSession: { key: 'session_id', id: 'live-session' } }) }) @@ -3856,6 +3861,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { it('captures resumable provider session metadata before dropping sleep-time rows', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' + const now = Date.now() seedStore(store, { worktreesByRepo: { @@ -3875,7 +3881,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { agentType: 'codex' }, 'Codex', - { updatedAt: 1000, stateStartedAt: 1000 }, + { updatedAt: now, stateStartedAt: now }, { tabId: 'tab-1', worktreeId: wt }, { providerSession: { key: 'session_id', id: 'codex-session-1' } } ) @@ -3889,13 +3895,14 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { tabId: 'tab-1', worktreeId: wt, agent: 'codex', + origin: 'worktree-sleep', providerSession: { key: 'session_id', id: 'codex-session-1' }, prompt: 'resume this', terminalTitle: 'Codex' }) }) - it('captures only allowlisted sleeping pane sessions when requested', async () => { + it('skips allowlisted done live sleeping pane sessions during manual sleep', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' @@ -3946,10 +3953,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { }) const state = store.getState() - expect(state.sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({ - paneKey: 'tab-1:live', - providerSession: { key: 'session_id', id: 'live-session' } - }) + expect(state.sleepingAgentSessionsByPaneKey['tab-1:live']).toBeUndefined() expect(state.sleepingAgentSessionsByPaneKey['tab-1:retained']).toBeUndefined() }) @@ -4076,6 +4080,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { }) expect(state.sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({ paneKey: 'tab-1:live', + origin: 'worktree-sleep', providerSession: { key: 'session_id', id: 'live-session' } }) expect(state.retainedAgentsByPaneKey['tab-1:retained']).toBeUndefined() @@ -4140,6 +4145,9 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { }) const state = store.getState() + expect(state.sleepingAgentSessionsByPaneKey['tab-1:interrupted']).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey['tab-1:working']).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey['tab-1:retained-only']).toBeUndefined() expect(state.retainedAgentsByPaneKey['tab-1:interrupted']).toBeUndefined() expect(state.retainedAgentsByPaneKey['tab-1:working']).toBeUndefined() expect(state.retainedAgentsByPaneKey['tab-1:retained-only']).toBeUndefined() diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 1c53d3e671d..0b1d68ac113 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -60,6 +60,7 @@ import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-co import { collectHibernatedCompletionEvidenceForWorktree, collectSleepingAgentSessionRecordsForWorktree, + removeSleepingRecordsReplacedByManualWorktreeSleep, type AgentStatusWorktreeShutdownReason } from './agent-status' @@ -1727,7 +1728,10 @@ export const createTerminalSlice: StateCreator const sleepingAgentSessionRecords = collectSleepingAgentSessionRecordsForWorktree( state, worktreeId, - paneKeys + { + paneKeys, + captureMode: 'completed-agent-hibernation' + } ) const retainedCompletionEvidence = collectHibernatedCompletionEvidenceForWorktree( state, @@ -1912,7 +1916,13 @@ export const createTerminalSlice: StateCreator const expectedRuntimePtyIds = sortedUniquePtyIds(opts?.expectedRuntimePtyIds) const shutdownPtyIds = sortedUniquePtyIds([...ptyIds, ...expectedRuntimePtyIds]) const sleepingAgentSessionRecords = keepIdentifiers - ? collectSleepingAgentSessionRecordsForWorktree(get(), worktreeId, opts?.sleepingPaneKeys) + ? collectSleepingAgentSessionRecordsForWorktree(get(), worktreeId, { + paneKeys: opts?.sleepingPaneKeys, + ...(shutdownReason === 'manual-sleep' ? { captureMode: 'manual-worktree-sleep' } : {}), + ...(shutdownReason === 'auto-hibernate-completed-agent' + ? { captureMode: 'completed-agent-hibernation' } + : {}) + }) : {} const retainedCompletionEvidence = shutdownReason === 'auto-hibernate-completed-agent' @@ -2178,12 +2188,22 @@ export const createTerminalSlice: StateCreator }) if (keepIdentifiers) { - set((s) => ({ - sleepingAgentSessionsByPaneKey: { - ...s.sleepingAgentSessionsByPaneKey, - ...sleepingAgentSessionRecords + set((s) => { + const base = + shutdownReason === 'manual-sleep' + ? removeSleepingRecordsReplacedByManualWorktreeSleep( + s.sleepingAgentSessionsByPaneKey, + worktreeId, + opts?.sleepingPaneKeys + ).records + : s.sleepingAgentSessionsByPaneKey + return { + sleepingAgentSessionsByPaneKey: { + ...base, + ...sleepingAgentSessionRecords + } } - })) + }) } else { get().clearSleepingAgentSessionsByWorktree(worktreeId) } diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index ae3568372c2..aacd548dfed 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -40,6 +40,7 @@ export type SleepingAgentSessionRecord = { updatedAt: number terminalTitle?: string lastAssistantMessage?: string + interrupted?: boolean connectionId?: string | null launchConfig?: SleepingAgentLaunchConfig /** How the record was captured. Worktree-sleep records (legacy records have diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index 76870f1d0b5..05ddd0810f1 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -329,6 +329,36 @@ describe('parseWorkspaceSession', () => { } }) + it('preserves interrupted sleeping agent records across hydration', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session' }, + prompt: 'continue', + state: 'done', + capturedAt: 10, + updatedAt: 9, + interrupted: true, + origin: 'worktree-sleep' + } + } + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.interrupted).toBe(true) + } + }) + it('preserves legacy live sleeping agent origins across hydration', () => { const result = parseWorkspaceSession({ activeRepoId: null, diff --git a/src/shared/workspace-session-sleeping-agents.ts b/src/shared/workspace-session-sleeping-agents.ts index a05731a89cb..ca110178481 100644 --- a/src/shared/workspace-session-sleeping-agents.ts +++ b/src/shared/workspace-session-sleeping-agents.ts @@ -77,6 +77,7 @@ const sleepingAgentSessionRecordSchema = z.object({ updatedAt: z.number().finite().positive(), terminalTitle: z.string().optional(), lastAssistantMessage: z.string().optional(), + interrupted: z.boolean().optional(), connectionId: z.string().nullable().optional(), launchConfig: sleepingAgentLaunchConfigSchema.optional(), origin: z.enum(['worktree-sleep', 'quit', 'live']).optional() From 0aedaf2d6981d67d0db2499dca06e5cfbefc3525 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:51:44 -0700 Subject: [PATCH 011/282] Fix agent hooks CLI runtime client test Exercise the agent hooks CLI test through main() with a mocked RuntimeClient class so typecheck no longer relies on a partial object cast. --- src/cli/handlers/agent-hooks.test.ts | 31 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/cli/handlers/agent-hooks.test.ts b/src/cli/handlers/agent-hooks.test.ts index 7e0638a07d9..57c79285712 100644 --- a/src/cli/handlers/agent-hooks.test.ts +++ b/src/cli/handlers/agent-hooks.test.ts @@ -4,19 +4,27 @@ import { join } from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultPersistedState } from '../../shared/constants' import type { PersistedState } from '../../shared/types' -import type { HandlerContext } from '../dispatch' const { applyAgentStatusHooksEnabledMock, + callMock, + getCliStatusMock, getDefaultUserDataPathMock, getManagedAgentHookStatusesMock } = vi.hoisted(() => ({ applyAgentStatusHooksEnabledMock: vi.fn(), + callMock: vi.fn(), + getCliStatusMock: vi.fn(() => Promise.resolve({ result: { runtime: { reachable: false } } })), getDefaultUserDataPathMock: vi.fn(), getManagedAgentHookStatusesMock: vi.fn() })) vi.mock('../runtime-client', () => { + class RuntimeClient { + call = callMock + getCliStatus = getCliStatusMock + } + class RuntimeClientError extends Error { readonly code: string @@ -27,6 +35,7 @@ vi.mock('../runtime-client', () => { } return { + RuntimeClient, RuntimeClientError, getDefaultUserDataPath: getDefaultUserDataPathMock } @@ -37,7 +46,7 @@ vi.mock('../../main/agent-hooks/managed-agent-hook-controls', () => ({ getManagedAgentHookStatuses: getManagedAgentHookStatusesMock })) -import { AGENT_HOOK_HANDLERS } from './agent-hooks' +import { main } from '../index' function readDataFile(userDataPath: string): PersistedState { return JSON.parse(readFileSync(join(userDataPath, 'orca-data.json'), 'utf-8')) as PersistedState @@ -48,21 +57,9 @@ function writeDataFile(userDataPath: string, state: PersistedState): void { writeFileSync(join(userDataPath, 'orca-data.json'), JSON.stringify(state, null, 2), 'utf-8') } -function createOfflineClient() { - return { - getCliStatus: vi.fn().mockResolvedValue({ result: { runtime: { reachable: false } } }), - call: vi.fn() - } -} - async function runAgentHooksOff(userDataPath: string): Promise { getDefaultUserDataPathMock.mockReturnValue(userDataPath) - await AGENT_HOOK_HANDLERS['agent hooks off']({ - flags: new Map(), - client: createOfflineClient() as HandlerContext['client'], - cwd: userDataPath, - json: true - }) + await main(['agent', 'hooks', 'off', '--json'], userDataPath) } describe('agent hooks CLI handler', () => { @@ -71,8 +68,12 @@ describe('agent hooks CLI handler', () => { beforeEach(() => { userDataPath = mkdtempSync(join(tmpdir(), 'orca-agent-hooks-cli-')) applyAgentStatusHooksEnabledMock.mockReturnValue([]) + callMock.mockReset() + getCliStatusMock.mockClear() getManagedAgentHookStatusesMock.mockReturnValue([]) + process.exitCode = undefined vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) }) afterEach(() => { From c0f1d386c71d52755f916453f09dbf68738f3f66 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 05:58:59 +0000 Subject: [PATCH 012/282] release: v1.4.91-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0b1ba5346cc..f7bf7648043 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.90", + "version": "1.4.91-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From bfb778570a87834ffbc35017fdc192ac145f934a Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Sun, 21 Jun 2026 23:09:01 -0700 Subject: [PATCH 013/282] feat(tabs): redesign tab splits and terminal pane discoverability (#5927) Co-authored-by: Cursor Co-authored-by: Orca Co-authored-by: brennanb2025 --- src/renderer/src/assets/terminal.css | 48 +- .../ContextualTourOverlay.tsx | 14 +- .../components/tab-bar/BrowserTab.test.tsx | 23 +- .../src/components/tab-bar/BrowserTab.tsx | 48 +- .../components/tab-bar/EditorFileTab.test.tsx | 31 +- .../src/components/tab-bar/EditorFileTab.tsx | 41 +- .../tab-bar/EditorFileTabContextMenu.test.tsx | 45 +- .../tab-bar/EditorFileTabContextMenu.tsx | 28 +- .../SortableTab.rename-shortcut.test.tsx | 90 ++- .../src/components/tab-bar/SortableTab.tsx | 43 +- .../tab-bar/SortableTabContextMenu.test.tsx | 246 +++++++ .../tab-bar/SortableTabContextMenu.tsx | 61 +- .../tab-bar/TabBar.context-menu.test.ts | 21 + .../src/components/tab-bar/TabBar.tsx | 36 +- .../TabBar.windows-shell-launch.test.ts | 21 + .../tab-bar/TabWorkspaceLayoutMenuSection.tsx | 63 ++ .../components/tab-bar/drop-indicator.test.ts | 7 + .../src/components/tab-bar/drop-indicator.ts | 8 +- .../request-active-terminal-pane-split.ts | 13 + .../tab-bar/tab-move-to-pane-column.test.ts | 118 ++++ .../tab-bar/tab-move-to-pane-column.ts | 65 ++ .../tab-bar/tab-strip-drag-scroll.ts | 68 ++ .../tab-strip-overflow-navigation.test.ts | 19 + .../tab-bar/tab-strip-overflow-navigation.ts | 41 +- .../tab-strip-pointer-activation.test.tsx | 112 +++ .../tab-bar/tab-strip-pointer-activation.ts | 72 ++ .../tab-bar/tab-title-tooltip.test.tsx | 12 +- .../tab-bar/web-runtime-tab-move-mirror.ts | 22 + .../tab-group/AiVaultSessionDropLayer.tsx | 3 +- .../tab-group/TabGroupDropOverlay.tsx | 24 +- .../components/tab-group/TabGroupPanel.tsx | 168 +++-- .../tab-group/TabGroupSplitLayout.test.ts | 70 +- .../tab-group/TabGroupSplitLayout.tsx | 110 +-- .../TabPaneColumnSplitDragOverlay.test.tsx | 61 ++ .../TabPaneColumnSplitDragOverlay.tsx | 56 ++ .../components/tab-group/tab-drag-context.tsx | 37 + .../tab-group/tab-drag-pointer.test.ts | 14 + .../components/tab-group/tab-drag-pointer.ts | 36 + .../tab-drag-preview-activation.test.ts | 83 +++ .../tab-group/tab-drag-preview-activation.ts | 205 ++++++ .../tab-group/tab-drag-preview-target.ts | 57 ++ .../tab-group/tab-drop-zone.test.ts | 32 + .../src/components/tab-group/tab-drop-zone.ts | 94 +++ .../tab-group-panel-split-target.test.ts | 638 ++++++++++++++++++ .../tab-group/tab-group-panel-split-target.ts | 264 ++++++++ .../tab-group/tab-insertion.test.ts | 39 +- .../tab-group/useTabDragSplit.test.ts | 223 +++++- .../components/tab-group/useTabDragSplit.ts | 437 +++++++----- .../useTabGroupWorkspaceModel.focus.test.ts | 43 ++ .../tab-group/useTabGroupWorkspaceModel.ts | 48 +- .../components/terminal-pane/TerminalPane.tsx | 234 ++----- .../TerminalPaneHeaderOverlay.test.tsx | 143 ++++ .../TerminalPaneHeaderOverlay.tsx | 298 ++++++++ .../terminal-pane/keyboard-handlers.ts | 49 +- ...ession-restored-banner-pane-state.test.tsx | 17 + .../session-restored-banner-pane-state.ts | 2 + ...inal-pane-split-with-inherited-cwd.test.ts | 90 +++ .../terminal-pane-split-with-inherited-cwd.ts | 47 ++ .../use-terminal-pane-context-menu.ts | 41 +- src/renderer/src/i18n/locales/en.json | 18 +- src/renderer/src/i18n/locales/es.json | 50 +- src/renderer/src/i18n/locales/ja.json | 50 +- src/renderer/src/i18n/locales/ko.json | 50 +- src/renderer/src/i18n/locales/zh.json | 50 +- .../src/lib/pane-manager/pane-drag-pointer.ts | 16 +- .../pane-manager/pane-drag-reorder.test.ts | 13 +- .../src/lib/pane-manager/pane-drag-reorder.ts | 46 +- .../lib/pane-manager/pane-drop-no-op.test.ts | 81 +++ .../pane-column-split-drop-no-op.test.ts | 82 +++ .../slices/pane-column-split-drop-no-op.ts | 71 ++ src/renderer/src/store/slices/tabs.test.ts | 38 +- src/renderer/src/store/slices/tabs.ts | 73 +- tests/e2e/terminal-panes.spec.ts | 17 +- 73 files changed, 4801 insertions(+), 933 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx create mode 100644 src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx create mode 100644 src/renderer/src/components/tab-bar/request-active-terminal-pane-split.ts create mode 100644 src/renderer/src/components/tab-bar/tab-move-to-pane-column.test.ts create mode 100644 src/renderer/src/components/tab-bar/tab-move-to-pane-column.ts create mode 100644 src/renderer/src/components/tab-bar/tab-strip-drag-scroll.ts create mode 100644 src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.test.ts create mode 100644 src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx create mode 100644 src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts create mode 100644 src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts create mode 100644 src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.test.tsx create mode 100644 src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.tsx create mode 100644 src/renderer/src/components/tab-group/tab-drag-context.tsx create mode 100644 src/renderer/src/components/tab-group/tab-drag-pointer.test.ts create mode 100644 src/renderer/src/components/tab-group/tab-drag-pointer.ts create mode 100644 src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts create mode 100644 src/renderer/src/components/tab-group/tab-drag-preview-activation.ts create mode 100644 src/renderer/src/components/tab-group/tab-drag-preview-target.ts create mode 100644 src/renderer/src/components/tab-group/tab-drop-zone.test.ts create mode 100644 src/renderer/src/components/tab-group/tab-drop-zone.ts create mode 100644 src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts create mode 100644 src/renderer/src/components/tab-group/tab-group-panel-split-target.ts create mode 100644 src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts create mode 100644 src/renderer/src/store/slices/pane-column-split-drop-no-op.test.ts create mode 100644 src/renderer/src/store/slices/pane-column-split-drop-no-op.ts diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index 0e4587f6a17..c78145379ca 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -217,11 +217,12 @@ border: 2px solid rgba(59, 130, 246, 0.5); border-radius: 4px; pointer-events: none; - transition: - left 80ms ease, - top 80ms ease, - width 80ms ease, - height 80ms ease; +} + +.tab-drop-overlay__label { + font-size: 11px; + color: var(--primary-foreground); + background: color-mix(in srgb, var(--chart-2) 20%, transparent); } /* ── Pane title bar ──────────────────────────────────── */ @@ -278,9 +279,9 @@ .pane-title-drag-handle { position: absolute; top: 0; - left: 0; - right: 0; - height: 12px; + left: 50%; + width: 32px; + height: var(--orca-pane-title-height); z-index: 2; display: flex; align-items: center; @@ -288,6 +289,7 @@ cursor: grab; opacity: 0; background: color-mix(in srgb, var(--orca-pane-title-fg) 36%, transparent); + transform: translateX(-50%); transition: opacity 150ms ease; } @@ -347,6 +349,12 @@ outline-offset: 1px; } +.pane-title-bar[data-chromeless] { + background: transparent; + border-bottom: none; + padding: 0 4px; +} + .pane-title-close { flex-shrink: 0; margin-left: auto; @@ -358,6 +366,24 @@ color 120ms ease; } +.pane-title-actions { + position: relative; + z-index: 3; +} + +.pane-title-split-trigger { + color: var(--orca-pane-title-button-fg, rgb(255 255 255 / 0.3)); + opacity: 0; + transition: opacity 120ms ease; +} + +.pane-title-bar[data-active-pane] .pane-title-split-trigger, +.pane-title-bar[data-active-pane] .pane-title-close, +.pane-title-bar:hover .pane-title-split-trigger, +.pane-title-bar:focus-within .pane-title-split-trigger { + opacity: 1; +} + .pane-title-bar:hover .pane-title-close, .pane-title-bar:focus-within .pane-title-close, .pane-title-close:focus-visible { @@ -367,7 +393,11 @@ .pane-title-close:hover, .pane-title-close:active, .pane-title-close[data-state='delayed-open'], -.pane-title-close[data-state='instant-open'] { +.pane-title-close[data-state='instant-open'], +.pane-title-split-trigger:hover, +.pane-title-split-trigger:active, +.pane-title-split-trigger[data-state='delayed-open'], +.pane-title-split-trigger[data-state='instant-open'] { background: transparent; color: var(--orca-pane-title-button-hover-fg, rgb(255 255 255 / 0.8)); } diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx index faa1d8eb24b..8129a6f20d8 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx @@ -21,10 +21,7 @@ import { handleContextualTourOverlayKeyDown, type ActiveTourRenderState } from './ContextualTourOverlaySurface' -import { - REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT, - type RequestActiveTerminalPaneSplitDetail -} from '@/constants/terminal' +import { requestActiveTerminalPaneSplit } from '@/components/tab-bar/request-active-terminal-pane-split' import { performContextualTourStepAction } from './contextual-tour-step-actions' import { openWorkspaceCreationComposerWithTourHandoff } from './workspace-creation-tour-handoff' @@ -321,14 +318,7 @@ export function ContextualTourOverlay(): JSX.Element | null { openModal, canCreateWorkspace, openWorkspaceComposer: openWorkspaceCreationComposerWithTourHandoff, - dispatchTerminalPaneSplit: (detail) => { - window.dispatchEvent( - new CustomEvent( - REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT, - { detail } - ) - ) - }, + dispatchTerminalPaneSplit: requestActiveTerminalPaneSplit, schedule: (callback) => { window.setTimeout(callback, 0) } diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index 1c40881b691..1c8b183821c 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -36,6 +36,13 @@ vi.mock('@dnd-kit/sortable', () => ({ }) })) +vi.mock('./tab-strip-pointer-activation', () => ({ + useTabStripPointerActivation: () => ({ + isPressed: false, + onPointerDown: vi.fn() + }) +})) + vi.mock('lucide-react', () => ({ Columns2: function Columns2(props: Record) { return { type: 'Columns2', props } @@ -76,6 +83,21 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenuSeparator: function DropdownMenuSeparator() { return { type: 'DropdownMenuSeparator', props: {} } }, + DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) { + return { type: 'DropdownMenuShortcut', props } + }, + DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) { + return { type: 'DropdownMenuLabel', props } + }, + DropdownMenuSub: function DropdownMenuSub(props: { children?: unknown }) { + return { type: 'DropdownMenuSub', props } + }, + DropdownMenuSubContent: function DropdownMenuSubContent(props: { children?: unknown }) { + return { type: 'DropdownMenuSubContent', props } + }, + DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: { children?: unknown }) { + return { type: 'DropdownMenuSubTrigger', props } + }, DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) { return { type: 'DropdownMenuTrigger', props } } @@ -133,7 +155,6 @@ async function renderBrowserTab(tab: BrowserTabState): Promise { onActivate: () => {}, onClose: () => {}, onCloseToRight: () => {}, - onSplitGroup: () => {}, onDuplicate: () => {}, onTogglePin: () => {}, dragData: { diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index 9e3e63ce809..04eb73649f5 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' -import { Globe, X, ExternalLink, Columns2, Rows2, Copy, Pin, PinOff } from 'lucide-react' +import { Globe, X, ExternalLink, Copy, Pin, PinOff } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -20,11 +20,14 @@ import { getDropIndicatorClasses, getTabRootStateClasses, getTabStripBorderClasses, + showsTabSelectionChrome, type DropIndicator } from './drop-indicator' import { preventMiddleButtonDefault } from './middle-button-default-guard' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' +import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection' function formatBrowserTabUrlLabel(url: string): string { if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') { @@ -107,7 +110,6 @@ export default function BrowserTab({ onActivate, onClose, onCloseToRight, - onSplitGroup, onDuplicate, onTogglePin, dragData, @@ -121,7 +123,6 @@ export default function BrowserTab({ onActivate: () => void onClose: () => void onCloseToRight: () => void - onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void onDuplicate: () => void onTogglePin: () => void dragData: TabDragItemData @@ -170,6 +171,11 @@ export default function BrowserTab({ return () => window.removeEventListener('blur', dismiss) }, [menuOpen]) + const { isPressed, onPointerDown: onTabPointerDown } = useTabStripPointerActivation({ + onActivate + }) + const showsSelectionChrome = showsTabSelectionChrome(isActive, isPressed) + const tabRoot = (
{ - if (e.button !== 0) { - return - } - onActivate() - listeners?.onPointerDown?.(e) + onTabPointerDown( + e, + listeners?.onPointerDown as ((event: React.PointerEvent) => void) | undefined + ) }} onMouseDown={(e) => { if (e.button === 1) { @@ -202,7 +207,7 @@ export default function BrowserTab({ } }} > - {isActive && } + {showsSelectionChrome && } {/* Why: the browser tab icon is the only non-terminal, non-editor surface in the tab strip. Coloring the Globe blue (matching the in-app browser's identity and the default tab insertion bar) @@ -219,7 +224,7 @@ export default function BrowserTab({ {!isPinned && ( + ), + DropdownMenuLabel: ({ children }: { children?: ReactNode }) =>
{children}
, + DropdownMenuSeparator: () => null, + DropdownMenuSub: ({ children }: { children?: ReactNode }) => children, + DropdownMenuSubContent: ({ children }: { children?: ReactNode }) => children, + DropdownMenuSubTrigger: ({ children }: { children?: ReactNode }) => ( + + ), + DropdownMenuShortcut: ({ children }: { children?: ReactNode }) => children, + DropdownMenuTrigger: ({ children }: { children?: ReactNode }) => children +})) + +vi.mock('lucide-react', () => ({ + PanelBottomClose: () => null, + PanelRightClose: () => null, + Pin: () => null, + PinOff: () => null +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('../../store', () => ({ + useAppStore: Object.assign( + (selector: (state: Record) => unknown) => selector(storeMock.state), + { + getState: () => storeMock.state + } + ) +})) + +const mounted: { container: HTMLDivElement; root: Root }[] = [] + +function renderMenu(overrides: Partial> = {}): { + container: HTMLDivElement + root: Root + onActivate: ReturnType +} { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const onActivate = vi.fn() + act(() => { + root.render( + + ) + }) + mounted.push({ container, root }) + return { container, root, onActivate } +} + +function getButton(container: HTMLElement, label: string): HTMLButtonElement { + const button = Array.from(container.querySelectorAll('button')).find((candidate) => + candidate.textContent?.includes(label) + ) + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Missing button: ${label}`) + } + return button +} + +function getLastSplitEvent(spy: ReturnType): CustomEvent { + const event = spy.mock.calls.at(-1)?.[0] + if (!(event instanceof CustomEvent)) { + throw new Error('Expected a split request event') + } + return event +} + +beforeEach(() => { + storeMock.dropUnifiedTab.mockReset() + storeMock.state = { + keybindings: {}, + dropUnifiedTab: storeMock.dropUnifiedTab, + groupsByWorktree: { + 'wt-1': [ + { + id: 'group-1', + worktreeId: 'wt-1', + activeTabId: 'tab-1', + tabOrder: ['tab-1', 'tab-2'] + } + ] + }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + id: 'tab-1', + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'terminal', + entityId: 'term-1', + label: 'bash', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + } + } +}) + +afterEach(() => { + for (const { container, root } of mounted.splice(0)) { + act(() => root.unmount()) + container.remove() + } + vi.restoreAllMocks() +}) + +describe('requestActiveTerminalPaneSplit', () => { + it('dispatches the active terminal pane split event', () => { + const dispatchSpy = vi.spyOn(window, 'dispatchEvent') + + requestActiveTerminalPaneSplit({ tabId: 'term-1', direction: 'vertical' }) + + expect(dispatchSpy).toHaveBeenCalledTimes(1) + const event = dispatchSpy.mock.calls[0]?.[0] as CustomEvent + expect(event.type).toBe(REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT) + expect(event.detail).toEqual({ + tabId: 'term-1', + direction: 'vertical' + }) + }) +}) + +describe('SortableTabContextMenu', () => { + it('dispatches split requests and activates inactive terminal tabs first', () => { + const dispatchSpy = vi.spyOn(window, 'dispatchEvent') + const { container, onActivate } = renderMenu({ isActive: false }) + + act(() => getButton(container, 'Split terminal right').click()) + expect(onActivate).toHaveBeenCalledWith('term-1') + expect(getLastSplitEvent(dispatchSpy).detail).toEqual({ + tabId: 'term-1', + direction: 'vertical' + }) + + dispatchSpy.mockClear() + act(() => getButton(container, 'Split terminal down').click()) + expect(getLastSplitEvent(dispatchSpy).detail).toEqual({ + tabId: 'term-1', + direction: 'horizontal' + }) + }) + + it('renders split actions and routes directions to the move path', () => { + storeMock.dropUnifiedTab.mockReturnValue(true) + const { container } = renderMenu() + + expect(container.textContent).toContain('Move Tab to Split') + + act(() => getButton(container, 'Right').click()) + expect(storeMock.dropUnifiedTab).toHaveBeenCalledWith('tab-1', { + groupId: 'group-1', + splitDirection: 'right' + }) + }) + + it('hides split actions for a single-tab group', () => { + storeMock.state = { + ...storeMock.state, + groupsByWorktree: { + 'wt-1': [ + { + id: 'group-1', + worktreeId: 'wt-1', + activeTabId: 'tab-1', + tabOrder: ['tab-1'] + } + ] + } + } + const { container } = renderMenu() + + expect(container.textContent).not.toContain('Move Tab to Split') + }) +}) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index 00b1678c954..2997ab2a29c 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -1,13 +1,18 @@ -import { Columns2, Pin, PinOff, Rows2 } from 'lucide-react' +import { PanelBottomClose, PanelRightClose, Pin, PinOff } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuShortcut, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import type { TerminalTab } from '../../../../shared/types' +import { useAppStore } from '../../store' +import { formatShortcutLabel } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' +import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection' +import { requestActiveTerminalPaneSplit } from './request-active-terminal-pane-split' const TAB_COLORS = [ { @@ -74,37 +79,54 @@ const TAB_COLORS = [ type SortableTabContextMenuProps = { tab: TerminalTab + unifiedTabId: string + groupId: string + isActive: boolean open: boolean point: { x: number; y: number } tabCount: number hasTabsToRight: boolean isPinned: boolean onOpenChange: (open: boolean) => void + onActivate: (tabId: string) => void onClose: (tabId: string) => void onCloseOthers: (tabId: string) => void onCloseToRight: (tabId: string) => void onRenameOpen: () => void onSetTabColor: (tabId: string, color: string | null) => void onTogglePin: () => void - onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void } export function SortableTabContextMenu({ tab, + unifiedTabId, + groupId, + isActive, open, point, tabCount, hasTabsToRight, isPinned, onOpenChange, + onActivate, onClose, onCloseOthers, onCloseToRight, onRenameOpen, onSetTabColor, - onTogglePin, - onSplitGroup + onTogglePin }: SortableTabContextMenuProps): React.JSX.Element { + const keybindings = useAppStore((state) => state.keybindings) + const splitRightShortcut = formatShortcutLabel('terminal.splitRight', keybindings) + const splitDownShortcut = formatShortcutLabel('terminal.splitDown', keybindings) + + const splitActiveTerminalPane = (direction: 'vertical' | 'horizontal'): void => { + if (!isActive) { + onActivate(tab.id) + } + requestActiveTerminalPaneSplit({ tabId: tab.id, direction }) + } + return ( @@ -115,23 +137,24 @@ export function SortableTabContextMenu({ style={{ left: point.x, top: point.y }} /> - - onSplitGroup('up', tab.id)}> - - {translate('auto.components.tab.bar.SortableTabContextMenu.591f9b12c1', 'Split Up')} + + splitActiveTerminalPane('vertical')}> + + {translate( + 'auto.components.tab.bar.SortableTabContextMenu.splitTerminalRight', + 'Split terminal right' + )} + {splitRightShortcut} - onSplitGroup('down', tab.id)}> - - {translate('auto.components.tab.bar.SortableTabContextMenu.af80ed83c1', 'Split Down')} - - onSplitGroup('left', tab.id)}> - - {translate('auto.components.tab.bar.SortableTabContextMenu.0ce4bae39d', 'Split Left')} - - onSplitGroup('right', tab.id)}> - - {translate('auto.components.tab.bar.SortableTabContextMenu.21132389e9', 'Split Right')} + splitActiveTerminalPane('horizontal')}> + + {translate( + 'auto.components.tab.bar.SortableTabContextMenu.splitTerminalDown', + 'Split terminal down' + )} + {splitDownShortcut} + {isPinned ? : } diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index 6e694be58d4..fd5d8ca9a90 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -88,6 +88,15 @@ vi.mock('@dnd-kit/sortable', () => ({ } })) +vi.mock('./tab-strip-drag-scroll', () => ({ + useTabStripDragScrollHandlers: () => ({ + isTabDragActive: false, + onDragScrollStartEnter: vi.fn(), + onDragScrollEndEnter: vi.fn(), + onDragScrollLeave: vi.fn() + }) +})) + const useAppStoreExport = (selector: Parameters[0]): unknown => useAppStoreMock(selector) useAppStoreExport.getState = vi.fn(() => ({ @@ -174,6 +183,18 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) { return { type: 'DropdownMenuShortcut', props } }, + DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) { + return { type: 'DropdownMenuLabel', props } + }, + DropdownMenuSub: function DropdownMenuSub(props: { children?: unknown }) { + return { type: 'DropdownMenuSub', props } + }, + DropdownMenuSubContent: function DropdownMenuSubContent(props: { children?: unknown }) { + return { type: 'DropdownMenuSubContent', props } + }, + DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: { children?: unknown }) { + return { type: 'DropdownMenuSubTrigger', props } + }, DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) { return { type: 'DropdownMenuTrigger', props } } diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 7282692ddc5..313186f46f2 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -75,6 +75,7 @@ import { translate } from '@/i18n/i18n' import { TabStripScrollIndicator } from './TabStripScrollIndicator' import { getTabStripScrollMaskClassName } from './tab-strip-scroll-metrics' import { useTabStripOverflowNavigation } from './tab-strip-overflow-navigation' +import { useTabStripDragScrollHandlers } from './tab-strip-drag-scroll' const isWindows = navigator.userAgent.includes('Windows') const isMacOs = navigator.userAgent.includes('Mac') @@ -137,10 +138,6 @@ type TabBarProps = { onMakePreviewFilePermanent?: (fileId: string, tabId?: string) => void onPinFile?: (fileId: string, tabId?: string) => void tabBarOrder?: string[] - onCreateSplitGroup?: ( - direction: 'left' | 'right' | 'up' | 'down', - sourceVisibleTabId?: string - ) => void hoveredTabInsertion?: HoveredTabInsertion | null /** Floating workspace panels are rounded; skip tab top borders that clash with the curve. */ tabStripChrome?: 'default' | 'floating-panel' @@ -266,7 +263,6 @@ function TabBarInner({ onMakePreviewFilePermanent, onPinFile, tabBarOrder, - onCreateSplitGroup, hoveredTabInsertion, tabStripChrome = 'default' }: TabBarProps): React.JSX.Element { @@ -981,6 +977,10 @@ function TabBarInner({ tabCount: orderedItems.length, worktreeId }) + const tabStripDragScroll = useTabStripDragScrollHandlers(scrollTabStrip, { + start: tabStripOverflowState.canScrollStart, + end: tabStripOverflowState.canScrollEnd + }) return (
scrollTabStrip('start')} + onPointerEnter={tabStripDragScroll.onDragScrollStartEnter} + onPointerLeave={tabStripDragScroll.onDragScrollLeave} > @@ -1069,6 +1074,8 @@ function TabBarInner({ togglePinned(item)} onToggleExpand={onTogglePaneExpand} - onSplitGroup={(direction, sourceVisibleTabId) => - onCreateSplitGroup?.(direction, sourceVisibleTabId) - } dragData={dragData} dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} includeTopTabBorder={includeTopTabBorder} @@ -1105,9 +1109,6 @@ function TabBarInner({ onActivate={() => onActivateBrowserTab?.(item.id)} onClose={() => onCloseBrowserTab?.(item.id)} onCloseToRight={() => onCloseToRight(item.id)} - onSplitGroup={(direction, sourceVisibleTabId) => - onCreateSplitGroup?.(direction, sourceVisibleTabId) - } onDuplicate={() => onDuplicateBrowserTab?.(item.id)} onTogglePin={() => togglePinned(item)} dragData={dragData} @@ -1143,9 +1144,6 @@ function TabBarInner({ onCloseAll={() => onCloseAllFiles?.()} onMakePermanent={() => {}} onTogglePin={() => togglePinned(item)} - onSplitGroup={(direction, sourceVisibleTabId) => - onCreateSplitGroup?.(direction, sourceVisibleTabId) - } dragData={dragData} dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} includeTopTabBorder={includeTopTabBorder} @@ -1171,9 +1169,6 @@ function TabBarInner({ onMakePreviewFilePermanent?.(item.data.id, item.data.tabId) } onTogglePin={() => togglePinned(item)} - onSplitGroup={(direction, sourceVisibleTabId) => - onCreateSplitGroup?.(direction, sourceVisibleTabId) - } dragData={dragData} dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} includeTopTabBorder={includeTopTabBorder} @@ -1196,8 +1191,11 @@ function TabBarInner({ 'auto.components.tab.bar.TabBar.232e075b07', 'Scroll tabs right' )} - disabled={!tabStripOverflowState.canScrollEnd} + aria-disabled={!tabStripOverflowState.canScrollEnd} + disabled={!tabStripDragScroll.isTabDragActive && !tabStripOverflowState.canScrollEnd} onClick={() => scrollTabStrip('end')} + onPointerEnter={tabStripDragScroll.onDragScrollEndEnter} + onPointerLeave={tabStripDragScroll.onDragScrollLeave} > diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts index 4c8c34168f5..4a7da1c89de 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts @@ -140,6 +140,15 @@ vi.mock('@dnd-kit/sortable', () => ({ } })) +vi.mock('./tab-strip-drag-scroll', () => ({ + useTabStripDragScrollHandlers: () => ({ + isTabDragActive: false, + onDragScrollStartEnter: vi.fn(), + onDragScrollEndEnter: vi.fn(), + onDragScrollLeave: vi.fn() + }) +})) + const useAppStoreExport = (selector: Parameters[0]): unknown => useAppStoreMock(selector) useAppStoreExport.getState = vi.fn(() => ({ @@ -237,6 +246,18 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) { return { type: 'DropdownMenuShortcut', props } }, + DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) { + return { type: 'DropdownMenuLabel', props } + }, + DropdownMenuSub: function DropdownMenuSub(props: { children?: unknown }) { + return { type: 'DropdownMenuSub', props } + }, + DropdownMenuSubContent: function DropdownMenuSubContent(props: { children?: unknown }) { + return { type: 'DropdownMenuSubContent', props } + }, + DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: { children?: unknown }) { + return { type: 'DropdownMenuSubTrigger', props } + }, DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) { return { type: 'DropdownMenuTrigger', props } } diff --git a/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx b/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx new file mode 100644 index 00000000000..819bb6d328f --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx @@ -0,0 +1,63 @@ +import { + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuItem +} from '@/components/ui/dropdown-menu' +import type { TabSplitDirection } from '../../store/slices/tabs' +import { translate } from '@/i18n/i18n' +import { canMoveTabToNewPaneColumn, moveTabToNewPaneColumn } from './tab-move-to-pane-column' + +const PANE_COLUMN_DIRECTIONS: TabSplitDirection[] = ['right', 'left', 'down', 'up'] + +function paneColumnDirectionLabel(direction: TabSplitDirection): string { + switch (direction) { + case 'right': + return translate('auto.components.tab.bar.TabWorkspaceLayoutMenuSection.right', 'Right') + case 'left': + return translate('auto.components.tab.bar.TabWorkspaceLayoutMenuSection.left', 'Left') + case 'down': + return translate('auto.components.tab.bar.TabWorkspaceLayoutMenuSection.down', 'Down') + case 'up': + return translate('auto.components.tab.bar.TabWorkspaceLayoutMenuSection.up', 'Up') + } +} + +export function TabWorkspaceLayoutMenuSection({ + unifiedTabId, + groupId +}: { + unifiedTabId: string + groupId: string +}): React.JSX.Element | null { + if (!canMoveTabToNewPaneColumn(unifiedTabId, groupId)) { + return null + } + + return ( + <> + + + + {translate( + 'auto.components.tab.bar.TabWorkspaceLayoutMenuSection.moveToPaneColumn', + 'Move Tab to Split' + )} + + + {PANE_COLUMN_DIRECTIONS.map((direction) => ( + { + moveTabToNewPaneColumn({ unifiedTabId, groupId, direction }) + }} + > + {paneColumnDirectionLabel(direction)} + + ))} + + + + ) +} diff --git a/src/renderer/src/components/tab-bar/drop-indicator.test.ts b/src/renderer/src/components/tab-bar/drop-indicator.test.ts index db3a8140c91..4a1ea418bcb 100644 --- a/src/renderer/src/components/tab-bar/drop-indicator.test.ts +++ b/src/renderer/src/components/tab-bar/drop-indicator.test.ts @@ -84,4 +84,11 @@ describe('getTabRootStateClasses', () => { expect(classes).toContain('text-muted-foreground') expect(classes).toContain('hover:text-foreground') }) + + it('returns the selected-tab surface treatment while pressed before activation', () => { + const classes = getTabRootStateClasses(false, true) + expect(classes).toContain('bg-[color-mix(in_srgb,var(--foreground)_6%,var(--card))]') + expect(classes).toContain('text-foreground') + expect(classes).not.toContain('hover:text-foreground') + }) }) diff --git a/src/renderer/src/components/tab-bar/drop-indicator.ts b/src/renderer/src/components/tab-bar/drop-indicator.ts index ab9f0ef5623..fd2d0977116 100644 --- a/src/renderer/src/components/tab-bar/drop-indicator.ts +++ b/src/renderer/src/components/tab-bar/drop-indicator.ts @@ -28,8 +28,12 @@ export function getDropIndicatorClasses(dropIndicator: DropIndicator): string { export const ACTIVE_TAB_INDICATOR_CLASSES = 'pointer-events-none absolute inset-x-0 bottom-0 h-[2px] bg-[color-mix(in_srgb,var(--foreground)_60%,var(--card))] z-10' -export function getTabRootStateClasses(isActive: boolean): string { - return isActive +export function showsTabSelectionChrome(isActive: boolean, isPressed = false): boolean { + return isActive || isPressed +} + +export function getTabRootStateClasses(isActive: boolean, isPressed = false): string { + return showsTabSelectionChrome(isActive, isPressed) ? 'bg-[color-mix(in_srgb,var(--foreground)_6%,var(--card))] text-foreground' : 'bg-card text-muted-foreground hover:text-foreground' } diff --git a/src/renderer/src/components/tab-bar/request-active-terminal-pane-split.ts b/src/renderer/src/components/tab-bar/request-active-terminal-pane-split.ts new file mode 100644 index 00000000000..b8c6837b8bb --- /dev/null +++ b/src/renderer/src/components/tab-bar/request-active-terminal-pane-split.ts @@ -0,0 +1,13 @@ +import { + REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT, + type RequestActiveTerminalPaneSplitDetail +} from '@/constants/terminal' + +export function requestActiveTerminalPaneSplit(detail: RequestActiveTerminalPaneSplitDetail): void { + window.dispatchEvent( + new CustomEvent( + REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT, + { detail } + ) + ) +} diff --git a/src/renderer/src/components/tab-bar/tab-move-to-pane-column.test.ts b/src/renderer/src/components/tab-bar/tab-move-to-pane-column.test.ts new file mode 100644 index 00000000000..5c9abb972a7 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-move-to-pane-column.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '../../store' +import type { Tab } from '../../../../shared/types' +import { canMoveTabToNewPaneColumn, moveTabToNewPaneColumn } from './tab-move-to-pane-column' + +const WT = 'wt-1' + +const mocks = vi.hoisted(() => ({ + mirrorWebRuntimeTabMove: vi.fn() +})) + +vi.mock('./web-runtime-tab-move-mirror', () => ({ + mirrorWebRuntimeTabMove: mocks.mirrorWebRuntimeTabMove +})) + +describe('tab-move-to-pane-column', () => { + beforeEach(() => { + mocks.mirrorWebRuntimeTabMove.mockReset() + useAppStore.setState({ + activeWorktreeId: WT, + groupsByWorktree: { + [WT]: [ + { + id: 'group-1', + worktreeId: WT, + activeTabId: 'tab-a', + tabOrder: ['tab-a', 'tab-b'] + } + ] + }, + unifiedTabsByWorktree: { + [WT]: [ + { + id: 'tab-a', + groupId: 'group-1', + worktreeId: WT, + contentType: 'terminal', + entityId: 'term-a', + label: 'A', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } satisfies Tab, + { + id: 'tab-b', + groupId: 'group-1', + worktreeId: WT, + contentType: 'terminal', + entityId: 'term-b', + label: 'B', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 1 + } satisfies Tab + ] + }, + layoutByWorktree: { + [WT]: { type: 'leaf', groupId: 'group-1' } + } + }) + }) + + it('allows moving when the source group has more than one tab', () => { + expect(canMoveTabToNewPaneColumn('tab-b', 'group-1')).toBe(true) + }) + + it('blocks moving the only tab in a group', () => { + useAppStore.setState({ + groupsByWorktree: { + [WT]: [ + { + id: 'group-1', + worktreeId: WT, + activeTabId: 'tab-a', + tabOrder: ['tab-a'] + } + ] + } + }) + + expect(canMoveTabToNewPaneColumn('tab-a', 'group-1')).toBe(false) + expect( + moveTabToNewPaneColumn({ unifiedTabId: 'tab-a', groupId: 'group-1', direction: 'right' }) + ).toBe(false) + }) + + it('creates a sibling split pane via dropUnifiedTab', () => { + const dropUnifiedTab = vi.fn(() => true) + useAppStore.setState({ dropUnifiedTab } as Partial>) + + expect( + moveTabToNewPaneColumn({ unifiedTabId: 'tab-b', groupId: 'group-1', direction: 'right' }) + ).toBe(true) + expect(dropUnifiedTab).toHaveBeenCalledWith('tab-b', { + groupId: 'group-1', + splitDirection: 'right' + }) + expect(mocks.mirrorWebRuntimeTabMove).toHaveBeenCalledWith({ + kind: 'split', + worktreeId: WT, + tabId: 'tab-b', + targetGroupId: 'group-1', + splitDirection: 'right' + }) + }) + + it('does not mirror when the local store rejects the move', () => { + const dropUnifiedTab = vi.fn(() => false) + useAppStore.setState({ dropUnifiedTab } as Partial>) + + expect( + moveTabToNewPaneColumn({ unifiedTabId: 'tab-b', groupId: 'group-1', direction: 'right' }) + ).toBe(false) + expect(mocks.mirrorWebRuntimeTabMove).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/tab-bar/tab-move-to-pane-column.ts b/src/renderer/src/components/tab-bar/tab-move-to-pane-column.ts new file mode 100644 index 00000000000..1d3de0bb916 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-move-to-pane-column.ts @@ -0,0 +1,65 @@ +import { useAppStore } from '../../store' +import type { TabSplitDirection } from '../../store/slices/tabs' +import { mirrorWebRuntimeTabMove } from './web-runtime-tab-move-mirror' + +type TabMovePaneColumnState = Pick< + ReturnType, + 'unifiedTabsByWorktree' | 'groupsByWorktree' +> + +export function canMoveTabToNewPaneColumnFromState( + state: TabMovePaneColumnState, + unifiedTabId: string, + groupId: string +): boolean { + for (const [worktreeId, tabs] of Object.entries(state.unifiedTabsByWorktree)) { + const tab = tabs.find((candidate) => candidate.id === unifiedTabId) + if (!tab || tab.groupId !== groupId) { + continue + } + const group = (state.groupsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === groupId + ) + if (!group) { + return false + } + // Why: mirror dropUnifiedTab — splitting the only tab in a group onto an + // adjacent split pane is a layout no-op the store rejects. + return group.tabOrder.length > 1 + } + return false +} + +export function canMoveTabToNewPaneColumn(unifiedTabId: string, groupId: string): boolean { + return canMoveTabToNewPaneColumnFromState(useAppStore.getState(), unifiedTabId, groupId) +} + +export function moveTabToNewPaneColumn(args: { + unifiedTabId: string + groupId: string + direction: TabSplitDirection +}): boolean { + const state = useAppStore.getState() + const worktreeId = Object.entries(state.unifiedTabsByWorktree).find(([, tabs]) => + tabs.some( + (candidate) => candidate.id === args.unifiedTabId && candidate.groupId === args.groupId + ) + )?.[0] + if (!worktreeId || !canMoveTabToNewPaneColumnFromState(state, args.unifiedTabId, args.groupId)) { + return false + } + const moved = state.dropUnifiedTab(args.unifiedTabId, { + groupId: args.groupId, + splitDirection: args.direction + }) + if (moved) { + mirrorWebRuntimeTabMove({ + kind: 'split', + worktreeId, + tabId: args.unifiedTabId, + targetGroupId: args.groupId, + splitDirection: args.direction + }) + } + return moved +} diff --git a/src/renderer/src/components/tab-bar/tab-strip-drag-scroll.ts b/src/renderer/src/components/tab-bar/tab-strip-drag-scroll.ts new file mode 100644 index 00000000000..2b5c95434cc --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-strip-drag-scroll.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useRef } from 'react' +import { useTabDragActive } from '../tab-group/tab-drag-context' + +const TAB_STRIP_DRAG_SCROLL_INTERVAL_MS = 180 + +export function useTabStripDragScrollHandlers( + scrollTabStrip: (direction: 'start' | 'end', behavior?: ScrollBehavior) => void, + canScroll: { start: boolean; end: boolean } +): { + isTabDragActive: boolean + onDragScrollStartEnter: () => void + onDragScrollEndEnter: () => void + onDragScrollLeave: () => void +} { + const isTabDragActive = useTabDragActive() + const intervalRef = useRef(null) + const canScrollRef = useRef(canScroll) + canScrollRef.current = canScroll + + const stopDragScroll = useCallback((): void => { + if (intervalRef.current !== null) { + window.clearInterval(intervalRef.current) + intervalRef.current = null + } + }, []) + + const startDragScroll = useCallback( + (direction: 'start' | 'end'): void => { + stopDragScroll() + if (!isTabDragActive) { + return + } + + const canScrollInDirection = (): boolean => + direction === 'start' ? canScrollRef.current.start : canScrollRef.current.end + + if (!canScrollInDirection()) { + return + } + + const tick = (): void => { + if (!canScrollInDirection()) { + stopDragScroll() + return + } + scrollTabStrip(direction, 'auto') + } + tick() + intervalRef.current = window.setInterval(tick, TAB_STRIP_DRAG_SCROLL_INTERVAL_MS) + }, + [isTabDragActive, scrollTabStrip, stopDragScroll] + ) + + useEffect(() => { + if (!isTabDragActive) { + stopDragScroll() + } + }, [isTabDragActive, stopDragScroll]) + + useEffect(() => () => stopDragScroll(), [stopDragScroll]) + + return { + isTabDragActive, + onDragScrollStartEnter: useCallback(() => startDragScroll('start'), [startDragScroll]), + onDragScrollEndEnter: useCallback(() => startDragScroll('end'), [startDragScroll]), + onDragScrollLeave: stopDragScroll + } +} diff --git a/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.test.ts b/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.test.ts new file mode 100644 index 00000000000..4ec5fcfbb76 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from 'vitest' +import { scrollTabStripByStep } from './tab-strip-overflow-navigation' + +describe('scrollTabStripByStep', () => { + it('scrolls instantly when requested for drag-hover navigation', () => { + const scrollBy = vi.fn() + const el = { + clientWidth: 200, + scrollBy + } as unknown as HTMLElement + + scrollTabStripByStep(el, 'end', 'auto') + + expect(scrollBy).toHaveBeenCalledWith({ + left: 150, + behavior: 'auto' + }) + }) +}) diff --git a/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts b/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts index 0c2da0521df..72d66783239 100644 --- a/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts +++ b/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts @@ -9,6 +9,21 @@ import { const TAB_STRIP_SCROLL_FRACTION = 0.75 const TAB_STRIP_MIN_SCROLL_STEP_PX = 120 +export function scrollTabStripByStep( + el: HTMLElement, + direction: 'start' | 'end', + behavior: ScrollBehavior = 'smooth' +): void { + const scrollStep = Math.max( + TAB_STRIP_MIN_SCROLL_STEP_PX, + el.clientWidth * TAB_STRIP_SCROLL_FRACTION + ) + el.scrollBy({ + left: direction === 'start' ? -scrollStep : scrollStep, + behavior + }) +} + const EMPTY_TAB_STRIP_OVERFLOW_STATE: TabStripScrollMetrics = { hasOverflow: false, canScrollStart: false, @@ -30,7 +45,7 @@ export function useTabStripOverflowNavigation({ }): { tabStripRef: RefObject tabStripOverflowState: TabStripScrollMetrics - scrollTabStrip: (direction: 'start' | 'end') => void + scrollTabStrip: (direction: 'start' | 'end', behavior?: ScrollBehavior) => void } { const tabStripRef = useRef(null) const prevStripLenRef = useRef<{ worktreeId: string; len: number } | null>(null) @@ -48,20 +63,16 @@ export function useTabStripOverflowNavigation({ sameTabStripScrollMetrics(previous, next) ? previous : next ) }, []) - const scrollTabStrip = useCallback((direction: 'start' | 'end'): void => { - const el = tabStripRef.current - if (!el) { - return - } - const scrollStep = Math.max( - TAB_STRIP_MIN_SCROLL_STEP_PX, - el.clientWidth * TAB_STRIP_SCROLL_FRACTION - ) - el.scrollBy({ - left: direction === 'start' ? -scrollStep : scrollStep, - behavior: 'smooth' - }) - }, []) + const scrollTabStrip = useCallback( + (direction: 'start' | 'end', behavior: ScrollBehavior = 'smooth'): void => { + const el = tabStripRef.current + if (!el) { + return + } + scrollTabStripByStep(el, direction, behavior) + }, + [] + ) useEffect(() => { const el = tabStripRef.current diff --git a/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx new file mode 100644 index 00000000000..1dacc025d7b --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx @@ -0,0 +1,112 @@ +/** + * @vitest-environment happy-dom + */ +import { act, useRef, useState } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TabDragProvider } from '../tab-group/tab-drag-context' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' + +function Probe({ onActivate }: { onActivate: () => void }): React.JSX.Element { + const [dragActive, setDragActive] = useState(false) + const dragActiveRef = useRef(false) + dragActiveRef.current = dragActive + + return ( + + + + ) +} + +function ProbeButton({ + onActivate, + onDragActiveChange +}: { + onActivate: () => void + onDragActiveChange: (active: boolean) => void +}): React.JSX.Element { + const { isPressed, onPointerDown } = useTabStripPointerActivation({ onActivate }) + return ( + <> + + + + ) +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function renderProbe(onActivate = vi.fn()): { + onActivate: ReturnType + tabButton: HTMLButtonElement + dragButton: HTMLButtonElement +} { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root?.render() + }) + const buttons = container.querySelectorAll('button') + return { + onActivate, + tabButton: buttons[0] as HTMLButtonElement, + dragButton: buttons[1] as HTMLButtonElement + } +} + +function dispatchPointer(target: EventTarget, type: string): void { + target.dispatchEvent(new MouseEvent(type, { bubbles: true, button: 0 })) +} + +afterEach(() => { + act(() => root?.unmount()) + container?.remove() + root = null + container = null +}) + +describe('useTabStripPointerActivation', () => { + it('defers activation until pointerup', () => { + const { onActivate, tabButton } = renderProbe() + + act(() => dispatchPointer(tabButton, 'pointerdown')) + expect(tabButton.dataset.pressed).toBe('true') + expect(onActivate).not.toHaveBeenCalled() + + act(() => dispatchPointer(window, 'pointerup')) + expect(tabButton.dataset.pressed).toBe('false') + expect(onActivate).toHaveBeenCalledTimes(1) + }) + + it('cancels pending activation on pointercancel', () => { + const { onActivate, tabButton } = renderProbe() + + act(() => dispatchPointer(tabButton, 'pointerdown')) + act(() => dispatchPointer(window, 'pointercancel')) + + expect(tabButton.dataset.pressed).toBe('false') + expect(onActivate).not.toHaveBeenCalled() + }) + + it('clears pending activation when a drag starts', () => { + const { onActivate, tabButton, dragButton } = renderProbe() + + act(() => dispatchPointer(tabButton, 'pointerdown')) + act(() => dragButton.click()) + act(() => dispatchPointer(window, 'pointerup')) + + expect(tabButton.dataset.pressed).toBe('false') + expect(onActivate).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts new file mode 100644 index 00000000000..e4ce56597ca --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts @@ -0,0 +1,72 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import { useTabDragActive, useTabDragActiveRef } from '../tab-group/tab-drag-context' + +export function useTabStripPointerActivation({ + onActivate, + disabled = false +}: { + onActivate: () => void + disabled?: boolean +}): { + isPressed: boolean + onPointerDown: ( + event: React.PointerEvent, + dragListener?: (event: React.PointerEvent) => void + ) => void +} { + const isTabDragActive = useTabDragActive() + const isTabDragActiveRef = useTabDragActiveRef() + const [isPressed, setIsPressed] = useState(false) + const pendingActivationRef = useRef(false) + const onActivateRef = useRef(onActivate) + onActivateRef.current = onActivate + + useLayoutEffect(() => { + if (!isTabDragActive) { + return + } + pendingActivationRef.current = false + setIsPressed(false) + }, [isTabDragActive]) + + useLayoutEffect(() => { + if (!isPressed) { + return + } + const finishPointerPress = (event: PointerEvent): void => { + if (event.button !== 0) { + return + } + const shouldActivate = pendingActivationRef.current && !isTabDragActiveRef.current + pendingActivationRef.current = false + setIsPressed(false) + if (shouldActivate) { + onActivateRef.current() + } + } + const cancelPointerPress = (): void => { + pendingActivationRef.current = false + setIsPressed(false) + } + window.addEventListener('pointerup', finishPointerPress) + window.addEventListener('pointercancel', cancelPointerPress) + return () => { + window.removeEventListener('pointerup', finishPointerPress) + window.removeEventListener('pointercancel', cancelPointerPress) + } + }, [isPressed, isTabDragActiveRef]) + + const onPointerDown = useCallback( + (event: React.PointerEvent, dragListener?: (event: React.PointerEvent) => void) => { + if (disabled || event.button !== 0) { + return + } + pendingActivationRef.current = true + setIsPressed(true) + dragListener?.(event) + }, + [disabled] + ) + + return { isPressed, onPointerDown } +} diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx index de2cb6ca348..f6fa605e219 100644 --- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -62,6 +62,10 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenuItem: ({ children }: { children?: ReactNode }) => <>{children}, DropdownMenuSeparator: () => null, DropdownMenuShortcut: ({ children }: { children?: ReactNode }) => <>{children}, + DropdownMenuLabel: ({ children }: { children?: ReactNode }) => <>{children}, + DropdownMenuSub: ({ children }: { children?: ReactNode }) => <>{children}, + DropdownMenuSubContent: ({ children }: { children?: ReactNode }) => <>{children}, + DropdownMenuSubTrigger: ({ children }: { children?: ReactNode }) => <>{children}, DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children} })) @@ -241,6 +245,8 @@ describe('tab title tooltips', () => { const markup = renderToStaticMarkup( { onSetTabColor={vi.fn()} onTogglePin={vi.fn()} onToggleExpand={vi.fn()} - onSplitGroup={vi.fn()} dragData={makeDragData('terminal', 'terminal-1')} /> ) @@ -273,6 +278,8 @@ describe('tab title tooltips', () => { const markup = renderToStaticMarkup( { onSetTabColor={vi.fn()} onTogglePin={vi.fn()} onToggleExpand={vi.fn()} - onSplitGroup={vi.fn()} dragData={makeDragData('terminal', 'terminal-1')} /> ) @@ -309,7 +315,6 @@ describe('tab title tooltips', () => { onActivate={vi.fn()} onClose={vi.fn()} onCloseToRight={vi.fn()} - onSplitGroup={vi.fn()} onDuplicate={vi.fn()} onTogglePin={vi.fn()} dragData={makeDragData('browser', 'browser-1')} @@ -340,7 +345,6 @@ describe('tab title tooltips', () => { onCloseAll={vi.fn()} onMakePermanent={vi.fn()} onTogglePin={vi.fn()} - onSplitGroup={vi.fn()} dragData={makeDragData('editor', 'editor-tab-1')} /> ) diff --git a/src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts b/src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts new file mode 100644 index 00000000000..fac4ecb6672 --- /dev/null +++ b/src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts @@ -0,0 +1,22 @@ +import type { RuntimeMobileSessionTabMove } from '../../../../shared/runtime-types' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { useAppStore } from '../../store' +import { + isWebRuntimeSessionActive, + moveWebRuntimeSessionTab +} from '../../runtime/web-runtime-session' + +export function mirrorWebRuntimeTabMove( + args: RuntimeMobileSessionTabMove & { + worktreeId: string + } +): void { + const environmentId = getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), args.worktreeId) + if (!isWebRuntimeSessionActive(environmentId)) { + return + } + void moveWebRuntimeSessionTab({ + ...args, + environmentId + }) +} diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index 0427a2916c8..91587d9d0e5 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -9,7 +9,8 @@ import { readAiVaultSessionDragData } from '@/lib/ai-vault-session-drag' import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session' -import { resolveDropZone, type TabDropZone } from './useTabDragSplit' +import { resolveDropZone } from './tab-drop-zone' +import type { TabDropZone } from './useTabDragSplit' import { translate } from '@/i18n/i18n' type PaneDropTarget = { diff --git a/src/renderer/src/components/tab-group/TabGroupDropOverlay.tsx b/src/renderer/src/components/tab-group/TabGroupDropOverlay.tsx index 48bfcf3b5ba..07d73077fad 100644 --- a/src/renderer/src/components/tab-group/TabGroupDropOverlay.tsx +++ b/src/renderer/src/components/tab-group/TabGroupDropOverlay.tsx @@ -1,5 +1,6 @@ import type { CSSProperties } from 'react' import type { TabDropZone } from './useTabDragSplit' +import { translate } from '@/i18n/i18n' function getOverlayStyle(zone: TabDropZone): CSSProperties { switch (zone) { @@ -16,8 +17,27 @@ function getOverlayStyle(zone: TabDropZone): CSSProperties { } } -export default function TabGroupDropOverlay({ zone }: { zone: TabDropZone }): React.JSX.Element { +export default function TabGroupDropOverlay({ + zone, + showPaneColumnLabel = false, + fillContainer = false +}: { + zone: TabDropZone + showPaneColumnLabel?: boolean + /** When the parent already sizes the overlay to the target region. */ + fillContainer?: boolean +}): React.JSX.Element { return ( - @@ -207,8 +202,6 @@ function SplitNode({ touchesRightEdge={touchesRightEdge} touchesLeftEdge={isHorizontal ? false : touchesLeftEdge} isTabDragActive={isTabDragActive} - activeDropGroupId={activeDropGroupId} - activeDropZone={activeDropZone} hoveredTabInsertion={hoveredTabInsertion} />
@@ -231,22 +224,26 @@ export default function TabGroupSplitLayout({ const hasSplits = layout.type === 'split' return ( - - {/* Why: the 10px drag strip sits ABOVE the split layout — lifted out of + + {/* Why: the 10px drag strip sits ABOVE the split layout — lifted out of each pane — so vertical split resize handles don't extend into the window-drag region at the top. Only the split layout's own panes own the resize handles, while this strip keeps the whole top of the @@ -262,41 +259,48 @@ export default function TabGroupSplitLayout({ state. The leftmost pane suppresses its own `border-l` via `touchesLeftEdge`, so the seam is always exactly 1px — previously both painted and stacked into a 2px bar below the drag strip. */} -
-
- +
+
+ +
-
- {/* Why: the sortable tab is anchored inside its source tab strip (no + {/* Why: the sortable tab is anchored inside its source tab strip (no transform while dragging), and that strip uses overflow-hidden so the tab is invisible once the cursor leaves it. DragOverlay renders a ghost in a document-level portal that tracks the cursor across the whole window — the source tab keeps its spot, the ghost follows the cursor. */} - - {dragSplit.activeDrag ? : null} - - + + {dragSplit.activeDrag ? : null} + + {dragSplit.hoveredDropTarget && + dragSplit.hoveredDropTarget.zone !== 'center' && + dragSplit.hoveredDropTarget.panelRect ? ( + + ) : null} + + ) } diff --git a/src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.test.tsx b/src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.test.tsx new file mode 100644 index 00000000000..72ec9216560 --- /dev/null +++ b/src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.test.tsx @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest' + +const { createPortalMock } = vi.hoisted(() => ({ + createPortalMock: vi.fn((node: unknown) => node) +})) + +vi.mock('react-dom', () => ({ + createPortal: createPortalMock +})) + +import TabPaneColumnSplitDragOverlay from './TabPaneColumnSplitDragOverlay' + +function rect({ + left, + top, + width, + height +}: { + left: number + top: number + width: number + height: number +}): DOMRect { + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height + } as DOMRect +} + +type ReactElementLike = { + props: Record +} + +describe('TabPaneColumnSplitDragOverlay', () => { + it('renders from cached panel bounds without reading layout during render', () => { + const querySelector = vi.fn() + vi.stubGlobal('document', { + body: {}, + querySelector + }) + + const overlay = TabPaneColumnSplitDragOverlay({ + panelRect: rect({ left: 100, top: 20, width: 400, height: 300 }), + zone: 'right' + }) + + expect(querySelector).not.toHaveBeenCalled() + expect((overlay as ReactElementLike).props.style).toEqual({ + top: 20, + left: 300, + width: 200, + height: 300 + }) + + vi.unstubAllGlobals() + }) +}) diff --git a/src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.tsx b/src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.tsx new file mode 100644 index 00000000000..3437f08236b --- /dev/null +++ b/src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.tsx @@ -0,0 +1,56 @@ +import { createPortal } from 'react-dom' +import type { CSSProperties } from 'react' +import TabGroupDropOverlay from './TabGroupDropOverlay' +import type { TabDropZone } from './useTabDragSplit' + +function getOverlayBounds( + rect: DOMRect, + zone: Exclude +): Pick { + switch (zone) { + case 'up': + return { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height / 2 + } + case 'down': + return { + top: rect.top + rect.height / 2, + left: rect.left, + width: rect.width, + height: rect.height / 2 + } + case 'left': + return { + top: rect.top, + left: rect.left, + width: rect.width / 2, + height: rect.height + } + case 'right': + return { + top: rect.top, + left: rect.left + rect.width / 2, + width: rect.width / 2, + height: rect.height + } + } +} + +export default function TabPaneColumnSplitDragOverlay({ + panelRect, + zone +}: { + panelRect: DOMRect + zone: Exclude +}): React.JSX.Element | null { + const bounds = getOverlayBounds(panelRect, zone) + return createPortal( + , + document.body + ) +} diff --git a/src/renderer/src/components/tab-group/tab-drag-context.tsx b/src/renderer/src/components/tab-group/tab-drag-context.tsx new file mode 100644 index 00000000000..3cf10ee7e1a --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drag-context.tsx @@ -0,0 +1,37 @@ +import { createContext, useContext, useMemo, type RefObject } from 'react' + +type TabDragContextValue = { + isTabDragActive: boolean + isTabDragActiveRef: RefObject +} + +const defaultRef: RefObject = { current: false } + +const TabDragContext = createContext({ + isTabDragActive: false, + isTabDragActiveRef: defaultRef +}) + +export function TabDragProvider({ + isTabDragActive, + isTabDragActiveRef, + children +}: { + isTabDragActive: boolean + isTabDragActiveRef: RefObject + children: React.ReactNode +}): React.JSX.Element { + const value = useMemo( + () => ({ isTabDragActive, isTabDragActiveRef }), + [isTabDragActive, isTabDragActiveRef] + ) + return {children} +} + +export function useTabDragActive(): boolean { + return useContext(TabDragContext).isTabDragActive +} + +export function useTabDragActiveRef(): RefObject { + return useContext(TabDragContext).isTabDragActiveRef +} diff --git a/src/renderer/src/components/tab-group/tab-drag-pointer.test.ts b/src/renderer/src/components/tab-group/tab-drag-pointer.test.ts new file mode 100644 index 00000000000..f2bf11ea8e4 --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drag-pointer.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { getDragPointer } from './tab-drag-pointer' + +describe('getDragPointer', () => { + it('uses the activator client coordinates plus drag delta', () => { + expect( + getDragPointer({ + activatorEvent: { clientX: 100, clientY: 40 }, + delta: { x: 250, y: 10 }, + active: { rect: { current: { initial: null, translated: null } } } + } as unknown as Parameters[0]) + ).toEqual({ x: 350, y: 50 }) + }) +}) diff --git a/src/renderer/src/components/tab-group/tab-drag-pointer.ts b/src/renderer/src/components/tab-group/tab-drag-pointer.ts new file mode 100644 index 00000000000..9310a2f810a --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drag-pointer.ts @@ -0,0 +1,36 @@ +import type { DragEndEvent, DragMoveEvent, DragOverEvent } from '@dnd-kit/core' + +type DragPointerEvent = Pick< + DragMoveEvent | DragOverEvent | DragEndEvent, + 'activatorEvent' | 'delta' | 'active' +> + +/** Pointer position during a tab drag. Why: sortable tabs stay visually anchored + * (no transform) while DragOverlay follows the cursor, so active.rect.translated + * never tracks the pointer and panel-edge split targets would stay wrong. */ +export function getDragPointer(event: DragPointerEvent): { x: number; y: number } | null { + const activator = event.activatorEvent + if ( + activator && + typeof activator === 'object' && + 'clientX' in activator && + 'clientY' in activator && + typeof activator.clientX === 'number' && + typeof activator.clientY === 'number' + ) { + return { + x: activator.clientX + event.delta.x, + y: activator.clientY + event.delta.y + } + } + + const initial = event.active.rect.current.initial + if (!initial) { + return null + } + + return { + x: initial.left + initial.width / 2 + event.delta.x, + y: initial.top + initial.height / 2 + event.delta.y + } +} diff --git a/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts b/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts new file mode 100644 index 00000000000..d6a373fc865 --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { Tab } from '../../../../shared/types' +import { useAppStore } from '../../store' +import { + applyDragPreviewTab, + captureTabDragActivationSnapshot, + restoreTabDragActivationSnapshot +} from './tab-drag-preview-activation' + +const WT = 'wt-preview-restore' + +describe('restoreTabDragActivationSnapshot', () => { + beforeEach(() => { + useAppStore.setState({ + activeWorktreeId: WT, + activeTabType: 'terminal', + activeTabId: 'terminal-1', + activeTabIdByWorktree: { [WT]: 'terminal-1' }, + activeTabTypeByWorktree: { [WT]: 'terminal' }, + groupsByWorktree: { + [WT]: [ + { + id: 'group-1', + worktreeId: WT, + activeTabId: 'tab-1', + tabOrder: ['tab-1', 'tab-2'] + } + ] + }, + unifiedTabsByWorktree: { + [WT]: [ + { + id: 'tab-1', + groupId: 'group-1', + worktreeId: WT, + contentType: 'terminal', + entityId: 'terminal-1', + label: 'Terminal 1', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } satisfies Tab, + { + id: 'tab-2', + groupId: 'group-1', + worktreeId: WT, + contentType: 'browser', + entityId: 'browser-1', + label: 'Browser', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 1 + } satisfies Tab + ] + }, + activeGroupIdByWorktree: { [WT]: 'group-1' } + }) + }) + + it('restores active-surface fields after a drag preview is cancelled', () => { + const snapshot = captureTabDragActivationSnapshot(WT) + + applyDragPreviewTab({ + worktreeId: WT, + groupId: 'group-1', + tabId: 'tab-2', + activeGroupId: 'group-1' + }) + + expect(useAppStore.getState().activeTabType).toBe('browser') + expect(useAppStore.getState().activeBrowserTabId).toBe('browser-1') + + restoreTabDragActivationSnapshot(WT, snapshot) + + const state = useAppStore.getState() + expect(state.groupsByWorktree[WT]?.[0]?.activeTabId).toBe('tab-1') + expect(state.activeTabType).toBe('terminal') + expect(state.activeTabId).toBe('terminal-1') + expect(state.activeTabIdByWorktree[WT]).toBe('terminal-1') + }) +}) diff --git a/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts b/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts new file mode 100644 index 00000000000..a0060271c99 --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts @@ -0,0 +1,205 @@ +import { useAppStore } from '../../store' +import type { AppState } from '../../store/types' + +export type TabDragActivationSnapshot = { + activeGroupId: string | null + activeTabIdByGroup: Record +} + +function previewActiveSurfacePatch( + state: AppState, + worktreeId: string, + groupId: string, + tabId: string | null +): Partial { + if (state.activeWorktreeId !== worktreeId || !tabId) { + return {} + } + const unifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.id === tabId && tab.groupId === groupId + ) + if (!unifiedTab) { + return {} + } + + const nextActiveTabTypeByWorktree = ( + tabType: AppState['activeTabType'] + ): AppState['activeTabTypeByWorktree'] => ({ + ...state.activeTabTypeByWorktree, + [worktreeId]: tabType + }) + + if (unifiedTab.contentType === 'terminal') { + return { + activeTabId: unifiedTab.entityId, + activeTabType: 'terminal', + activeTabIdByWorktree: { + ...state.activeTabIdByWorktree, + [worktreeId]: unifiedTab.entityId + }, + activeTabTypeByWorktree: nextActiveTabTypeByWorktree('terminal') + } + } + if (unifiedTab.contentType === 'browser') { + return { + activeBrowserTabId: unifiedTab.entityId, + activeTabType: 'browser', + activeBrowserTabIdByWorktree: { + ...state.activeBrowserTabIdByWorktree, + [worktreeId]: unifiedTab.entityId + }, + activeTabTypeByWorktree: nextActiveTabTypeByWorktree('browser') + } + } + if (unifiedTab.contentType === 'simulator') { + return { + activeTabType: 'simulator', + activeTabTypeByWorktree: nextActiveTabTypeByWorktree('simulator') + } + } + return { + activeFileId: unifiedTab.entityId, + activeTabType: 'editor', + activeFileIdByWorktree: { + ...state.activeFileIdByWorktree, + [worktreeId]: unifiedTab.entityId + }, + activeTabTypeByWorktree: nextActiveTabTypeByWorktree('editor') + } +} + +export function captureTabDragActivationSnapshot(worktreeId: string): TabDragActivationSnapshot { + const state = useAppStore.getState() + const groups = state.groupsByWorktree[worktreeId] ?? [] + return { + activeGroupId: state.activeGroupIdByWorktree[worktreeId] ?? null, + activeTabIdByGroup: Object.fromEntries(groups.map((group) => [group.id, group.activeTabId])) + } +} + +export function applyDragPreviewTab({ + worktreeId, + groupId, + tabId, + activeGroupId +}: { + worktreeId: string + groupId: string + tabId: string | null + activeGroupId: string +}): void { + useAppStore.setState((state): Partial => { + const groups = state.groupsByWorktree[worktreeId] ?? [] + const targetGroup = groups.find((group) => group.id === groupId) + const groupUnchanged = targetGroup?.activeTabId === tabId + const focusUnchanged = (state.activeGroupIdByWorktree[worktreeId] ?? null) === activeGroupId + const surfacePatch = previewActiveSurfacePatch(state, worktreeId, groupId, tabId) + if (groupUnchanged && focusUnchanged) { + return Object.keys(surfacePatch).length > 0 ? surfacePatch : {} + } + + const next: Partial = { ...surfacePatch } + if (!groupUnchanged) { + next.groupsByWorktree = { + ...state.groupsByWorktree, + [worktreeId]: groups.map((group) => + group.id === groupId ? { ...group, activeTabId: tabId } : group + ) + } + } + if (!focusUnchanged) { + next.activeGroupIdByWorktree = { + ...state.activeGroupIdByWorktree, + [worktreeId]: activeGroupId + } + } + return next + }) +} + +export function restoreTabDragActivationSnapshot( + worktreeId: string, + snapshot: TabDragActivationSnapshot +): void { + useAppStore.setState((state): Partial => { + const groups = state.groupsByWorktree[worktreeId] ?? [] + const groupsUnchanged = groups.every( + (group) => (snapshot.activeTabIdByGroup[group.id] ?? null) === group.activeTabId + ) + const focusUnchanged = + (state.activeGroupIdByWorktree[worktreeId] ?? null) === snapshot.activeGroupId + + const next: Partial = {} + if (!groupsUnchanged) { + next.groupsByWorktree = { + ...state.groupsByWorktree, + [worktreeId]: groups.map((group) => ({ + ...group, + activeTabId: snapshot.activeTabIdByGroup[group.id] ?? null + })) + } + } + if (!focusUnchanged) { + if (snapshot.activeGroupId === null) { + const nextActiveGroupIdByWorktree = { ...state.activeGroupIdByWorktree } + delete nextActiveGroupIdByWorktree[worktreeId] + next.activeGroupIdByWorktree = nextActiveGroupIdByWorktree + } else { + next.activeGroupIdByWorktree = { + ...state.activeGroupIdByWorktree, + [worktreeId]: snapshot.activeGroupId + } + } + } + + const restoredGroupId = snapshot.activeGroupId + if (restoredGroupId) { + const restoredTabId = snapshot.activeTabIdByGroup[restoredGroupId] ?? null + Object.assign( + next, + previewActiveSurfacePatch(state, worktreeId, restoredGroupId, restoredTabId) + ) + } + + if (Object.keys(next).length === 0) { + return {} + } + + return next + }) +} + +export function restoreSourceGroupActiveTabAfterCrossGroupDrop({ + worktreeId, + snapshot, + sourceGroupId, + movedTabId +}: { + worktreeId: string + snapshot: TabDragActivationSnapshot + sourceGroupId: string + movedTabId: string +}): void { + const preDragActiveTabId = snapshot.activeTabIdByGroup[sourceGroupId] ?? null + // Why: dropUnifiedTab already picks the next active tab when the moved tab + // was the source group's selection; only preview contamination needs undo. + if (preDragActiveTabId === movedTabId) { + return + } + + useAppStore.setState((state): Partial => { + const groups = state.groupsByWorktree[worktreeId] ?? [] + const sourceGroup = groups.find((group) => group.id === sourceGroupId) + if (!sourceGroup || sourceGroup.activeTabId === preDragActiveTabId) { + return {} + } + return { + groupsByWorktree: { + ...state.groupsByWorktree, + [worktreeId]: groups.map((group) => + group.id === sourceGroupId ? { ...group, activeTabId: preDragActiveTabId } : group + ) + } + } + }) +} diff --git a/src/renderer/src/components/tab-group/tab-drag-preview-target.ts b/src/renderer/src/components/tab-group/tab-drag-preview-target.ts new file mode 100644 index 00000000000..b4c36a0dcdc --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drag-preview-target.ts @@ -0,0 +1,57 @@ +import type { TabDragItemData } from './useTabDragSplit' +import { isPaneDropData, isTabDragData } from './useTabDragSplit' + +export type DragPreviewTabTarget = { + groupId: string + tabId: string | null +} + +export function resolveDragPreviewTabId({ + activeDrag, + overData, + preDragActiveTabIdByGroup, + lastHoveredTabPreview = null +}: { + activeDrag: TabDragItemData + overData: unknown + preDragActiveTabIdByGroup: Record + lastHoveredTabPreview?: DragPreviewTabTarget | null +}): DragPreviewTabTarget { + const sourceGroupId = activeDrag.groupId + const sourcePreDragTabId = preDragActiveTabIdByGroup[sourceGroupId] ?? null + + if (isTabDragData(overData) && overData.unifiedTabId !== activeDrag.unifiedTabId) { + // Why: tab-strip hovers target split drops or reorder slots. Previewing + // the hovered tab's content reads like an in-tab split even though the drop + // opens a new split. + return { groupId: sourceGroupId, tabId: sourcePreDragTabId } + } + + if (isPaneDropData(overData)) { + if (lastHoveredTabPreview?.groupId === overData.groupId && lastHoveredTabPreview.tabId) { + return lastHoveredTabPreview + } + if (overData.groupId === sourceGroupId) { + return { groupId: sourceGroupId, tabId: sourcePreDragTabId } + } + return { + groupId: overData.groupId, + tabId: preDragActiveTabIdByGroup[overData.groupId] ?? null + } + } + + return { groupId: sourceGroupId, tabId: sourcePreDragTabId } +} + +export function resolveSourceGroupRestoreOnDrop( + activeData: TabDragItemData, + targetGroupId: string, + restoreSnapshot: boolean +): TabDragItemData | undefined { + // Why: same-group splits keep the previewed active tab in the source pane via + // dropUnifiedTab; restoring the pre-drag snapshot would undo that preview. + if (restoreSnapshot || activeData.groupId === targetGroupId) { + return undefined + } + return activeData +} diff --git a/src/renderer/src/components/tab-group/tab-drop-zone.test.ts b/src/renderer/src/components/tab-group/tab-drop-zone.test.ts new file mode 100644 index 00000000000..2690d7d97fe --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drop-zone.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { resolvePaneColumnEdgeZone, TAB_GROUP_TAB_STRIP_HEIGHT_PX } from './tab-drop-zone' + +describe('resolvePaneColumnEdgeZone', () => { + const panelRect = { left: 0, top: 0, width: 300, height: 200 } + + it('returns right on the outer horizontal band in the body', () => { + expect(resolvePaneColumnEdgeZone(panelRect, { x: 260, y: 100 })).toBe('right') + }) + + it('returns null in the center band of the body', () => { + expect(resolvePaneColumnEdgeZone(panelRect, { x: 150, y: 100 })).toBeNull() + }) + + it('does not return up while the pointer is still in the tab strip', () => { + expect( + resolvePaneColumnEdgeZone(panelRect, { + x: 150, + y: TAB_GROUP_TAB_STRIP_HEIGHT_PX - 1 + }) + ).toBeNull() + }) + + it('returns up on the top edge of the pane body', () => { + expect( + resolvePaneColumnEdgeZone(panelRect, { + x: 150, + y: TAB_GROUP_TAB_STRIP_HEIGHT_PX + 5 + }) + ).toBe('up') + }) +}) diff --git a/src/renderer/src/components/tab-group/tab-drop-zone.ts b/src/renderer/src/components/tab-group/tab-drop-zone.ts new file mode 100644 index 00000000000..10291e2389c --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drop-zone.ts @@ -0,0 +1,94 @@ +import type { TabDropZone } from './useTabDragSplit' + +/** Matches TabGroupPanel tab row height (`h-[32px]`). */ +export const TAB_GROUP_TAB_STRIP_HEIGHT_PX = 32 + +type PaneRect = { left: number; top: number; width: number; height: number } + +export function resolveDropZone( + rect: { left: number; top: number; width: number; height: number }, + point: { x: number; y: number } +): TabDropZone { + const localX = point.x - rect.left + const localY = point.y - rect.top + const edgeWidthThreshold = rect.width * 0.1 + const edgeHeightThreshold = rect.height * 0.1 + const splitWidthThreshold = rect.width / 3 + + // Why: VS Code keeps a center "merge" zone while biasing side-by-side drops + // toward left/right, which feels much more stable than a generic nearest-edge + // calculation once a workspace has nested splits. + if ( + localX > edgeWidthThreshold && + localX < rect.width - edgeWidthThreshold && + localY > edgeHeightThreshold && + localY < rect.height - edgeHeightThreshold + ) { + return 'center' + } + + if (localX < splitWidthThreshold) { + return 'left' + } + if (localX > splitWidthThreshold * 2) { + return 'right' + } + return localY < rect.height / 2 ? 'up' : 'down' +} + +/** Outer band of a split pane panel where drags open another split. */ +export function resolvePaneColumnEdgeZone( + panelRect: PaneRect, + point: { x: number; y: number }, + options?: { + bodyRect?: PaneRect | null + tabStripHeightPx?: number + } +): Exclude | null { + const localX = point.x - panelRect.left + const horizontalEdge = panelRect.width * 0.2 + + if (localX < horizontalEdge) { + return 'left' + } + if (localX > panelRect.width - horizontalEdge) { + return 'right' + } + + const tabStripHeight = options?.tabStripHeightPx ?? TAB_GROUP_TAB_STRIP_HEIGHT_PX + const tabStripBottom = panelRect.top + tabStripHeight + // Why: the tab strip is for reorder/insertion targets. Vertical pane splits + // belong on the terminal/editor body edges only. + if (point.y < tabStripBottom) { + return null + } + + const bodyRect = + options?.bodyRect ?? + ({ + left: panelRect.left, + top: tabStripBottom, + width: panelRect.width, + height: Math.max(0, panelRect.height - tabStripHeight) + } satisfies PaneRect) + + if (bodyRect.height <= 0) { + return null + } + + const bodyLocalY = point.y - bodyRect.top + const verticalEdge = bodyRect.height * 0.2 + + if (bodyLocalY < verticalEdge) { + return 'up' + } + if (bodyLocalY > bodyRect.height - verticalEdge) { + return 'down' + } + return null +} + +export type PaneColumnSplitTarget = { + groupId: string + zone: Exclude +} diff --git a/src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts b/src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts new file mode 100644 index 00000000000..ceca4d16962 --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts @@ -0,0 +1,638 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TabGroup, TabGroupLayoutNode } from '../../../../shared/types' +import type { TabDragItemData } from './useTabDragSplit' +import { + captureTabGroupPanelGeometrySnapshot, + findTabGroupPanelUnderPointer, + resolveActivePaneColumnSplitTarget, + resolvePanelEdgePaneColumnSplit +} from './tab-group-panel-split-target' +import { TAB_GROUP_TAB_STRIP_HEIGHT_PX } from './tab-drop-zone' + +function makeDragData(overrides: Partial = {}): TabDragItemData { + return { + kind: 'tab', + worktreeId: 'wt-1', + groupId: 'group-1', + unifiedTabId: 'tab-1', + visibleTabId: 'tab-1', + tabType: 'terminal', + label: 'tab-1', + ...overrides + } +} + +function makeEvent({ + activeData, + overData = null, + pointer = { x: 0, y: 0 } +}: { + activeData: TabDragItemData + overData?: TabDragItemData | null + pointer?: { x: number; y: number } +}) { + return { + active: { data: { current: activeData } }, + over: overData + ? { + data: { current: overData }, + rect: { left: 500, width: 120, top: 0, height: 32 } + } + : null, + delta: { x: 0, y: 0 }, + activatorEvent: { clientX: pointer.x, clientY: pointer.y } + } as unknown as Parameters[0]['event'] +} + +function mockTabGroupRects(panelRect: DOMRect, bodyRect: DOMRect): void { + vi.stubGlobal('document', { + querySelector: vi.fn(() => ({ + getBoundingClientRect: () => bodyRect, + parentElement: { + getBoundingClientRect: () => panelRect + } + })), + querySelectorAll: vi.fn(() => [ + { + dataset: { + tabGroupBodyId: 'group-2', + worktreeId: 'wt-1' + } + } + ]) + }) +} + +function rect({ + left, + top, + width, + height +}: { + left: number + top: number + width: number + height: number +}): DOMRect { + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height + } as DOMRect +} + +function mockTabGroupGeometry( + entries: { + groupId: string + panelRect: DOMRect + bodyRect: DOMRect + counts?: { panelReads: number; bodyReads: number } + }[] +): { queryAll: ReturnType } { + const bodies = entries.map((entry) => ({ + dataset: { + tabGroupBodyId: entry.groupId, + worktreeId: 'wt-1' + }, + getBoundingClientRect: () => { + if (entry.counts) { + entry.counts.bodyReads += 1 + } + return entry.bodyRect + }, + parentElement: { + getBoundingClientRect: () => { + if (entry.counts) { + entry.counts.panelReads += 1 + } + return entry.panelRect + } + } + })) + const queryAll = vi.fn(() => bodies) + vi.stubGlobal('document', { + querySelector: vi.fn((selector: string) => { + const match = selector.match(/data-tab-group-body-id="([^"]+)"/) + return bodies.find((body) => body.dataset.tabGroupBodyId === match?.[1]) ?? null + }), + querySelectorAll: queryAll + }) + return { queryAll } +} + +function percentile(values: number[], p: number): number { + const sorted = [...values].sort((a, b) => a - b) + return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] +} + +function fourGroupFixture(): { + counts: { panelReads: number; bodyReads: number }[] + groupsByWorktree: Record + layoutByWorktree: Record + queryAll: ReturnType +} { + const counts = [ + { panelReads: 0, bodyReads: 0 }, + { panelReads: 0, bodyReads: 0 }, + { panelReads: 0, bodyReads: 0 }, + { panelReads: 0, bodyReads: 0 } + ] + const { queryAll } = mockTabGroupGeometry([ + { + groupId: 'group-1', + panelRect: rect({ left: 0, top: 0, width: 300, height: 600 }), + bodyRect: rect({ + left: 0, + top: TAB_GROUP_TAB_STRIP_HEIGHT_PX, + width: 300, + height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX + }), + counts: counts[0] + }, + { + groupId: 'group-2', + panelRect: rect({ left: 304, top: 0, width: 300, height: 600 }), + bodyRect: rect({ + left: 304, + top: TAB_GROUP_TAB_STRIP_HEIGHT_PX, + width: 300, + height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX + }), + counts: counts[1] + }, + { + groupId: 'group-3', + panelRect: rect({ left: 608, top: 0, width: 300, height: 600 }), + bodyRect: rect({ + left: 608, + top: TAB_GROUP_TAB_STRIP_HEIGHT_PX, + width: 300, + height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX + }), + counts: counts[2] + }, + { + groupId: 'group-4', + panelRect: rect({ left: 912, top: 0, width: 300, height: 600 }), + bodyRect: rect({ + left: 912, + top: TAB_GROUP_TAB_STRIP_HEIGHT_PX, + width: 300, + height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX + }), + counts: counts[3] + } + ]) + + return { + counts, + queryAll, + groupsByWorktree: { + 'wt-1': [ + { id: 'group-1', worktreeId: 'wt-1', activeTabId: 'tab-1', tabOrder: ['tab-1', 'tab-5'] }, + { id: 'group-2', worktreeId: 'wt-1', activeTabId: 'tab-2', tabOrder: ['tab-2'] }, + { id: 'group-3', worktreeId: 'wt-1', activeTabId: 'tab-3', tabOrder: ['tab-3'] }, + { id: 'group-4', worktreeId: 'wt-1', activeTabId: 'tab-4', tabOrder: ['tab-4'] } + ] + }, + layoutByWorktree: { + 'wt-1': { + type: 'split', + direction: 'horizontal', + ratio: 0.5, + first: { + type: 'split', + direction: 'horizontal', + ratio: 0.5, + first: { type: 'leaf', groupId: 'group-1' }, + second: { type: 'leaf', groupId: 'group-2' } + }, + second: { + type: 'split', + direction: 'horizontal', + ratio: 0.5, + first: { type: 'leaf', groupId: 'group-3' }, + second: { type: 'leaf', groupId: 'group-4' } + } + } + } + } +} + +const horizontalLayout: TabGroupLayoutNode = { + type: 'split', + direction: 'horizontal', + ratio: 0.5, + first: { type: 'leaf', groupId: 'group-1' }, + second: { type: 'leaf', groupId: 'group-2' } +} + +const twoGroupLayout = { + 'wt-1': [ + { id: 'group-1', worktreeId: 'wt-1', activeTabId: 'tab-1', tabOrder: ['tab-1'] }, + { id: 'group-2', worktreeId: 'wt-1', activeTabId: 'tab-2', tabOrder: ['tab-2'] } + ] +} + +const layoutByWorktree = { 'wt-1': horizontalLayout } + +describe('resolvePanelEdgePaneColumnSplit', () => { + const panelRect = { + left: 500, + top: 0, + width: 400, + height: 600, + right: 900, + bottom: 600 + } as DOMRect + const bodyRect = { + left: 500, + top: TAB_GROUP_TAB_STRIP_HEIGHT_PX, + width: 400, + height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX, + right: 900, + bottom: 600 + } as DOMRect + + beforeEach(() => { + mockTabGroupRects(panelRect, bodyRect) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('returns a right-edge split when the pointer is on the outer band', () => { + expect( + resolvePanelEdgePaneColumnSplit({ + activeDrag: makeDragData({ groupId: 'group-1' }), + targetGroupId: 'group-2', + worktreeId: 'wt-1', + pointer: { x: 880, y: 300 }, + groupsByWorktree: twoGroupLayout, + layoutByWorktree + }) + ).toEqual({ groupId: 'group-2', zone: 'right' }) + }) + + it('returns null in the center band so cross-group tab insertion can win', () => { + expect( + resolvePanelEdgePaneColumnSplit({ + activeDrag: makeDragData({ groupId: 'group-1' }), + targetGroupId: 'group-2', + worktreeId: 'wt-1', + pointer: { x: 700, y: 300 }, + groupsByWorktree: twoGroupLayout, + layoutByWorktree + }) + ).toBeNull() + }) + + it('does not treat the tab strip as a top split edge', () => { + expect( + resolvePanelEdgePaneColumnSplit({ + activeDrag: makeDragData({ groupId: 'group-2' }), + targetGroupId: 'group-1', + worktreeId: 'wt-1', + pointer: { x: 650, y: 16 }, + groupsByWorktree: twoGroupLayout, + layoutByWorktree + }) + ).toBeNull() + }) + + it('suppresses adjacent sibling split drops that would collapse back to the current layout', () => { + const group1Panel = { + left: 0, + top: 0, + width: 400, + height: 600, + right: 400, + bottom: 600 + } as DOMRect + + expect( + resolvePanelEdgePaneColumnSplit({ + activeDrag: makeDragData({ groupId: 'group-2', unifiedTabId: 'tab-2' }), + targetGroupId: 'group-1', + worktreeId: 'wt-1', + pointer: { x: 360, y: 300 }, + groupsByWorktree: twoGroupLayout, + layoutByWorktree, + panelRect: group1Panel + }) + ).toBeNull() + }) + + it('rejects stale panel targets when the pointer has left the panel bounds', () => { + expect( + resolvePanelEdgePaneColumnSplit({ + activeDrag: makeDragData({ groupId: 'group-1' }), + targetGroupId: 'group-2', + worktreeId: 'wt-1', + pointer: { x: 499, y: 300 }, + groupsByWorktree: twoGroupLayout, + layoutByWorktree, + panelRect + }) + ).toBeNull() + }) +}) + +describe('resolveActivePaneColumnSplitTarget', () => { + const panelRect = { + left: 500, + top: 0, + width: 400, + height: 600, + right: 900, + bottom: 600 + } as DOMRect + const bodyRect = { + left: 500, + top: TAB_GROUP_TAB_STRIP_HEIGHT_PX, + width: 400, + height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX, + right: 900, + bottom: 600 + } as DOMRect + + beforeEach(() => { + mockTabGroupRects(panelRect, bodyRect) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('resolves cross-group panel-edge splits without an dnd-kit over target', () => { + expect(findTabGroupPanelUnderPointer('wt-1', { x: 880, y: 300 })?.groupId).toBe('group-2') + expect( + resolveActivePaneColumnSplitTarget({ + event: makeEvent({ + activeData: makeDragData({ groupId: 'group-1' }), + overData: null, + pointer: { x: 880, y: 300 } + }), + groupsByWorktree: twoGroupLayout, + layoutByWorktree, + worktreeId: 'wt-1', + getDragPointer: () => ({ x: 880, y: 300 }) + }) + ).toEqual(expect.objectContaining({ groupId: 'group-2', zone: 'right' })) + }) + + it('skips pane-edge splits for cross-group tab-strip hovers', () => { + expect( + resolveActivePaneColumnSplitTarget({ + event: makeEvent({ + activeData: makeDragData({ groupId: 'group-2', unifiedTabId: 'tab-2' }), + overData: makeDragData({ + groupId: 'group-1', + unifiedTabId: 'tab-1', + visibleTabId: 'tab-1' + }), + pointer: { x: 650, y: 16 } + }), + groupsByWorktree: twoGroupLayout, + layoutByWorktree, + worktreeId: 'wt-1', + getDragPointer: () => ({ x: 650, y: 16 }) + }) + ).toBeNull() + }) + + it('resolves body-edge splits when a stale cross-group tab hover remains active', () => { + expect( + resolveActivePaneColumnSplitTarget({ + event: makeEvent({ + activeData: makeDragData({ groupId: 'group-1' }), + overData: makeDragData({ + groupId: 'group-2', + unifiedTabId: 'tab-2', + visibleTabId: 'tab-2' + }), + pointer: { x: 880, y: 300 } + }), + groupsByWorktree: twoGroupLayout, + layoutByWorktree, + worktreeId: 'wt-1', + getDragPointer: () => ({ x: 880, y: 300 }) + }) + ).toEqual(expect.objectContaining({ groupId: 'group-2', zone: 'right' })) + }) + + it('ignores stale over targets after the pointer leaves the cached panel', () => { + const geometry = { + entries: [ + { + groupId: 'group-2', + panelRect, + bodyRect + } + ], + byGroupId: new Map([['group-2', { groupId: 'group-2', panelRect, bodyRect }]]) + } + + expect( + resolveActivePaneColumnSplitTarget({ + event: makeEvent({ + activeData: makeDragData({ groupId: 'group-1' }), + overData: makeDragData({ + groupId: 'group-2', + unifiedTabId: 'tab-2', + visibleTabId: 'tab-2' + }), + pointer: { x: 499, y: 300 } + }), + groupsByWorktree: twoGroupLayout, + layoutByWorktree, + worktreeId: 'wt-1', + getDragPointer: () => ({ x: 499, y: 300 }), + geometry + }) + ).toBeNull() + }) + + it('does not create a split target while hovering over another tab in the same strip', () => { + const panelRect = rect({ left: 500, top: 0, width: 400, height: 600 }) + mockTabGroupGeometry([ + { + groupId: 'group-1', + panelRect, + bodyRect: rect({ + left: 500, + top: TAB_GROUP_TAB_STRIP_HEIGHT_PX, + width: 400, + height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX + }) + } + ]) + const geometry = captureTabGroupPanelGeometrySnapshot('wt-1') + + expect( + resolveActivePaneColumnSplitTarget({ + event: makeEvent({ + activeData: makeDragData({ groupId: 'group-1', unifiedTabId: 'tab-1' }), + overData: makeDragData({ + groupId: 'group-1', + unifiedTabId: 'tab-2', + visibleTabId: 'tab-2' + }), + pointer: { x: 560, y: 16 } + }), + groupsByWorktree: { + 'wt-1': [ + { + id: 'group-1', + worktreeId: 'wt-1', + activeTabId: 'tab-1', + tabOrder: ['tab-1', 'tab-2'] + } + ] + }, + layoutByWorktree: { + 'wt-1': { type: 'leaf', groupId: 'group-1' } + }, + worktreeId: 'wt-1', + getDragPointer: () => ({ x: 560, y: 16 }), + geometry + }) + ).toBeNull() + }) + + it('ignores same-group tab hovers after the pointer leaves the panel', () => { + const panelRect = rect({ left: 500, top: 0, width: 400, height: 600 }) + mockTabGroupGeometry([ + { + groupId: 'group-1', + panelRect, + bodyRect: rect({ + left: 500, + top: TAB_GROUP_TAB_STRIP_HEIGHT_PX, + width: 400, + height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX + }) + } + ]) + const geometry = captureTabGroupPanelGeometrySnapshot('wt-1') + + expect( + resolveActivePaneColumnSplitTarget({ + event: makeEvent({ + activeData: makeDragData({ groupId: 'group-1', unifiedTabId: 'tab-1' }), + overData: makeDragData({ + groupId: 'group-1', + unifiedTabId: 'tab-2', + visibleTabId: 'tab-2' + }), + pointer: { x: 560, y: 601 } + }), + groupsByWorktree: { + 'wt-1': [ + { + id: 'group-1', + worktreeId: 'wt-1', + activeTabId: 'tab-1', + tabOrder: ['tab-1', 'tab-2'] + } + ] + }, + layoutByWorktree: { + 'wt-1': { type: 'leaf', groupId: 'group-1' } + }, + worktreeId: 'wt-1', + getDragPointer: () => ({ x: 560, y: 601 }), + geometry + }) + ).toBeNull() + }) + + it('reuses one geometry snapshot across drag frames instead of measuring layout per move', () => { + const { + counts, + groupsByWorktree, + layoutByWorktree: fourGroupLayoutByWorktree, + queryAll + } = fourGroupFixture() + const geometry = captureTabGroupPanelGeometrySnapshot('wt-1') + + const points = [ + { x: 295, y: 300 }, + { x: 360, y: 300 }, + { x: 595, y: 300 }, + { x: 664, y: 300 }, + { x: 899, y: 300 }, + { x: 968, y: 300 }, + { x: 1204, y: 300 } + ] + for (const pointer of points) { + resolveActivePaneColumnSplitTarget({ + event: makeEvent({ + activeData: makeDragData({ groupId: 'group-1' }), + overData: null, + pointer + }), + groupsByWorktree, + layoutByWorktree: fourGroupLayoutByWorktree, + worktreeId: 'wt-1', + getDragPointer: () => pointer, + geometry + }) + } + + expect(queryAll).toHaveBeenCalledTimes(1) + expect(counts.reduce((sum, entry) => sum + entry.panelReads + entry.bodyReads, 0)).toBe(8) + }) + + it('reports cached split-target resolution timing without gating CI on wall-clock time', () => { + const { + counts, + groupsByWorktree, + layoutByWorktree: fourGroupLayoutByWorktree, + queryAll + } = fourGroupFixture() + const geometry = captureTabGroupPanelGeometrySnapshot('wt-1') + const points = [ + { x: 295, y: 300 }, + { x: 360, y: 300 }, + { x: 595, y: 300 }, + { x: 664, y: 300 }, + { x: 899, y: 300 }, + { x: 968, y: 300 }, + { x: 1204, y: 300 }, + { x: 650, y: 300 } + ] + const durations: number[] = [] + for (let index = 0; index < 1_000; index += 1) { + const pointer = points[index % points.length]! + const startedAt = performance.now() + resolveActivePaneColumnSplitTarget({ + event: makeEvent({ + activeData: makeDragData({ groupId: 'group-1' }), + overData: null, + pointer + }), + groupsByWorktree, + layoutByWorktree: fourGroupLayoutByWorktree, + worktreeId: 'wt-1', + getDragPointer: () => pointer, + geometry + }) + durations.push(performance.now() - startedAt) + } + + const p95Ms = percentile(durations, 0.95) + const maxMs = Math.max(...durations) + console.info( + `tab split preview resolver perf: p95=${p95Ms.toFixed(3)}ms max=${maxMs.toFixed( + 3 + )}ms samples=${durations.length}` + ) + expect(queryAll).toHaveBeenCalledTimes(1) + expect(counts.reduce((sum, entry) => sum + entry.panelReads + entry.bodyReads, 0)).toBe(8) + }) +}) diff --git a/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts b/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts new file mode 100644 index 00000000000..b78f383f26c --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts @@ -0,0 +1,264 @@ +import type { DragEndEvent, DragMoveEvent, DragOverEvent } from '@dnd-kit/core' +import type { TabGroup, TabGroupLayoutNode } from '../../../../shared/types' +import { isPaneColumnSplitDropNoOp } from '../../store/slices/pane-column-split-drop-no-op' +import { + resolvePaneColumnEdgeZone, + TAB_GROUP_TAB_STRIP_HEIGHT_PX, + type PaneColumnSplitTarget +} from './tab-drop-zone' +import { + canDropTabIntoPaneBody, + isPaneDropData, + isTabDragData, + type TabDragItemData +} from './useTabDragSplit' + +export type TabGroupPanelGeometryEntry = { + groupId: string + panelRect: DOMRect + bodyRect: DOMRect +} + +export type TabGroupPanelGeometrySnapshot = { + entries: TabGroupPanelGeometryEntry[] + byGroupId: Map +} + +export type ActivePaneColumnSplitTarget = PaneColumnSplitTarget & { + panelRect?: DOMRect +} + +function escapeCssAttrValue(value: string): string { + if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { + return CSS.escape(value) + } + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') +} + +function getTabGroupBodyElement(groupId: string, worktreeId: string): HTMLElement | null { + const escapedGroupId = escapeCssAttrValue(groupId) + const escapedWorktreeId = escapeCssAttrValue(worktreeId) + return document.querySelector( + `[data-tab-group-body-id="${escapedGroupId}"][data-worktree-id="${escapedWorktreeId}"]` + ) +} + +export function getTabGroupPanelRect(groupId: string, worktreeId: string): DOMRect | null { + return getTabGroupBodyElement(groupId, worktreeId)?.parentElement?.getBoundingClientRect() ?? null +} + +export function getTabGroupBodyRect(groupId: string, worktreeId: string): DOMRect | null { + return getTabGroupBodyElement(groupId, worktreeId)?.getBoundingClientRect() ?? null +} + +export function captureTabGroupPanelGeometrySnapshot( + worktreeId: string +): TabGroupPanelGeometrySnapshot { + const escapedWorktreeId = escapeCssAttrValue(worktreeId) + const bodies = document.querySelectorAll( + `[data-tab-group-body-id][data-worktree-id="${escapedWorktreeId}"]` + ) + const entries: TabGroupPanelGeometryEntry[] = [] + for (const body of bodies) { + const groupId = body.dataset.tabGroupBodyId + const panelElement = body.parentElement + if (!groupId || !panelElement) { + continue + } + entries.push({ + groupId, + panelRect: panelElement.getBoundingClientRect(), + bodyRect: body.getBoundingClientRect() + }) + } + + return { + entries, + byGroupId: new Map(entries.map((entry) => [entry.groupId, entry])) + } +} + +export function findTabGroupPanelUnderPointer( + worktreeId: string, + pointer: { x: number; y: number }, + options: { + geometry?: TabGroupPanelGeometrySnapshot | null + getPanelRect?: (groupId: string, worktreeId: string) => DOMRect | null + } = {} +): { groupId: string; panelRect: DOMRect } | null { + if (options.geometry) { + for (const entry of options.geometry.entries) { + const { panelRect } = entry + if ( + pointer.x >= panelRect.left && + pointer.x <= panelRect.right && + pointer.y >= panelRect.top && + pointer.y <= panelRect.bottom + ) { + return { groupId: entry.groupId, panelRect } + } + } + return null + } + + const getPanelRect = options.getPanelRect ?? getTabGroupPanelRect + const escapedWorktreeId = escapeCssAttrValue(worktreeId) + const bodies = document.querySelectorAll( + `[data-tab-group-body-id][data-worktree-id="${escapedWorktreeId}"]` + ) + for (const body of bodies) { + const groupId = body.dataset.tabGroupBodyId + if (!groupId) { + continue + } + const panelRect = getPanelRect(groupId, worktreeId) + if (!panelRect) { + continue + } + if ( + pointer.x >= panelRect.left && + pointer.x <= panelRect.right && + pointer.y >= panelRect.top && + pointer.y <= panelRect.bottom + ) { + return { groupId, panelRect } + } + } + return null +} + +export function resolvePanelEdgePaneColumnSplit({ + activeDrag, + targetGroupId, + worktreeId, + pointer, + groupsByWorktree, + layoutByWorktree, + panelRect: providedPanelRect, + bodyRect: providedBodyRect +}: { + activeDrag: TabDragItemData + targetGroupId: string + worktreeId: string + pointer: { x: number; y: number } + groupsByWorktree: Record + layoutByWorktree: Record + panelRect?: DOMRect | null + bodyRect?: DOMRect | null +}): PaneColumnSplitTarget | null { + const panelRect = providedPanelRect ?? getTabGroupPanelRect(targetGroupId, worktreeId) + if (!panelRect) { + return null + } + // Why: dnd-kit can keep a closest-center `over` target after the pointer + // leaves the pane; edge splits must only resolve inside the actual panel. + if ( + pointer.x < panelRect.left || + pointer.x > panelRect.left + panelRect.width || + pointer.y < panelRect.top || + pointer.y > panelRect.top + panelRect.height + ) { + return null + } + + const bodyRect = providedBodyRect ?? getTabGroupBodyRect(targetGroupId, worktreeId) + + const zone = resolvePaneColumnEdgeZone(panelRect, pointer, { + bodyRect: bodyRect ?? null, + tabStripHeightPx: TAB_GROUP_TAB_STRIP_HEIGHT_PX + }) + if (!zone) { + return null + } + + const sourceGroup = (groupsByWorktree[worktreeId] ?? []).find( + (group) => group.id === activeDrag.groupId + ) + if ( + isPaneColumnSplitDropNoOp({ + sourceGroupId: activeDrag.groupId, + targetGroupId, + splitDirection: zone, + sourceTabCount: sourceGroup?.tabOrder.length ?? 0, + layout: layoutByWorktree[worktreeId] + }) + ) { + return null + } + + if (activeDrag.groupId === targetGroupId) { + if ( + !canDropTabIntoPaneBody({ + activeDrag, + groupsByWorktree, + overGroupId: targetGroupId, + worktreeId + }) + ) { + return null + } + } + + return { groupId: targetGroupId, zone } +} + +export function resolveActivePaneColumnSplitTarget({ + event, + groupsByWorktree, + layoutByWorktree, + worktreeId, + getDragPointer, + geometry +}: { + event: DragMoveEvent | DragOverEvent | DragEndEvent + groupsByWorktree: Record + layoutByWorktree: Record + worktreeId: string + getDragPointer: (event: DragMoveEvent | DragOverEvent | DragEndEvent) => { + x: number + y: number + } | null + geometry?: TabGroupPanelGeometrySnapshot | null +}): ActivePaneColumnSplitTarget | null { + const activeData = event.active.data.current + const pointer = getDragPointer(event) + if (!isTabDragData(activeData) || !pointer) { + return null + } + + const overData = event.over?.data.current + const panelHit = findTabGroupPanelUnderPointer(worktreeId, pointer, { geometry }) + + if (isTabDragData(overData)) { + // Why: tab-strip drags target reorder/insertion slots. Split creation stays + // on pane/body edges so hovering over a tab never surprises the user with a + // new split. + if (!panelHit || pointer.y < panelHit.panelRect.top + TAB_GROUP_TAB_STRIP_HEIGHT_PX) { + return null + } + } + + const targetGroupId = + panelHit?.groupId ?? + (isTabDragData(overData) ? overData.groupId : null) ?? + (isPaneDropData(overData) ? overData.groupId : null) + + if (!targetGroupId) { + return null + } + + const targetGeometry = geometry?.byGroupId.get(targetGroupId) + const panelRect = + panelHit?.groupId === targetGroupId ? panelHit.panelRect : targetGeometry?.panelRect + const splitTarget = resolvePanelEdgePaneColumnSplit({ + activeDrag: activeData, + targetGroupId, + worktreeId, + pointer, + groupsByWorktree, + layoutByWorktree, + panelRect, + bodyRect: targetGeometry?.bodyRect + }) + return splitTarget ? { ...splitTarget, panelRect } : null +} diff --git a/src/renderer/src/components/tab-group/tab-insertion.test.ts b/src/renderer/src/components/tab-group/tab-insertion.test.ts index ab2719a4738..5acc6569b46 100644 --- a/src/renderer/src/components/tab-group/tab-insertion.test.ts +++ b/src/renderer/src/components/tab-group/tab-insertion.test.ts @@ -90,19 +90,18 @@ describe('resolveTabInsertion', () => { expect(resolveTabInsertion(event, isTabDragData, () => null)).toBeNull() }) - it('returns side "left" when cursor center is left of the over midpoint', () => { + it('returns side "left" when cursor is in the left reorder edge', () => { const overData = makeDragData({ unifiedTabId: 'tab-over', visibleTabId: 'tab-over', groupId: 'group-2' }) - // Over rect: left=100, width=100 → midpoint=150 + // Over rect: left=100, width=100 → left edge ends at 130 const event = makeDragEvent({ activeData: makeDragData({ unifiedTabId: 'tab-active' }), overData, overRect: { left: 100, width: 100 } }) - // Cursor at x=120, which is left of midpoint 150 const result = resolveTabInsertion(event, isTabDragData, () => ({ x: 120, y: 10 })) expect(result).toEqual({ groupId: 'group-2', @@ -111,19 +110,18 @@ describe('resolveTabInsertion', () => { }) }) - it('returns side "right" when cursor center is right of the over midpoint', () => { + it('returns side "right" when cursor is in the right reorder edge', () => { const overData = makeDragData({ unifiedTabId: 'tab-over', visibleTabId: 'tab-over', groupId: 'group-2' }) - // Over rect: left=100, width=100 → midpoint=150 + // Over rect: left=100, width=100 → right edge starts at 170 const event = makeDragEvent({ activeData: makeDragData({ unifiedTabId: 'tab-active' }), overData, overRect: { left: 100, width: 100 } }) - // Cursor at x=180, which is right of midpoint 150 const result = resolveTabInsertion(event, isTabDragData, () => ({ x: 180, y: 10 })) expect(result).toEqual({ groupId: 'group-2', @@ -132,24 +130,39 @@ describe('resolveTabInsertion', () => { }) }) - it('returns side "right" when cursor is exactly at the midpoint', () => { + it('uses midpoint insertion when reordering within the same pane', () => { + const overData = makeDragData({ + unifiedTabId: 'tab-over', + visibleTabId: 'tab-over', + groupId: 'group-1' + }) + const event = makeDragEvent({ + activeData: makeDragData({ unifiedTabId: 'tab-active', groupId: 'group-1' }), + overData, + overRect: { left: 0, width: 200 } + }) + expect(resolveTabInsertion(event, isTabDragData, () => ({ x: 100, y: 10 }))).toEqual({ + groupId: 'group-1', + visibleTabId: 'tab-over', + side: 'right' + }) + }) + + it('uses midpoint insertion when dragging across split panes', () => { const overData = makeDragData({ unifiedTabId: 'tab-over', visibleTabId: 'tab-over', groupId: 'group-2' }) - // Over rect: left=0, width=200 → midpoint=100 const event = makeDragEvent({ - activeData: makeDragData({ unifiedTabId: 'tab-active' }), + activeData: makeDragData({ unifiedTabId: 'tab-active', groupId: 'group-1' }), overData, overRect: { left: 0, width: 200 } }) - // Cursor at x=100 — exactly at midpoint, not < midpoint so → 'right' - const result = resolveTabInsertion(event, isTabDragData, () => ({ x: 100, y: 10 })) - expect(result).toEqual({ + expect(resolveTabInsertion(event, isTabDragData, () => ({ x: 80, y: 10 }))).toEqual({ groupId: 'group-2', visibleTabId: 'tab-over', - side: 'right' + side: 'left' }) }) }) diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts index 3ab2add57ea..6a1efe52481 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts @@ -1,12 +1,34 @@ -import { describe, expect, it } from 'vitest' -import type { TabGroup } from '../../../../shared/types' +/** + * @vitest-environment happy-dom + */ +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../shared/types' +import { useAppStore } from '../../store' import type { TabDragItemData } from './useTabDragSplit' -import { canDropTabIntoPaneBody } from './useTabDragSplit' +import { + canDropTabForPaneColumnSplit, + canDropTabIntoPaneBody, + useTabDragSplit +} from './useTabDragSplit' + +vi.mock('../browser-pane/webview-registry', () => ({ + acquireWebviewsDragPassthrough: vi.fn(() => vi.fn()) +})) + +vi.mock('../../runtime/web-runtime-session', () => ({ + isWebRuntimeSessionActive: vi.fn(() => false), + moveWebRuntimeSessionTab: vi.fn() +})) + +const WT = 'wt-1' +const mounted: { container: HTMLDivElement; root: Root }[] = [] function makeGroup(id: string, tabOrder: string[]): TabGroup { return { id, - worktreeId: 'wt-1', + worktreeId: WT, activeTabId: tabOrder[0] ?? null, tabOrder } @@ -15,7 +37,7 @@ function makeGroup(id: string, tabOrder: string[]): TabGroup { function makeDragData(groupId: string, unifiedTabId = 'tab-1'): TabDragItemData { return { kind: 'tab', - worktreeId: 'wt-1', + worktreeId: WT, groupId, unifiedTabId, visibleTabId: unifiedTabId, @@ -24,14 +46,145 @@ function makeDragData(groupId: string, unifiedTabId = 'tab-1'): TabDragItemData } } +function rect({ + left, + top, + width, + height +}: { + left: number + top: number + width: number + height: number +}): DOMRect { + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height + } as DOMRect +} + +function addPanelGeometry(groupId: string, panelRect: DOMRect, bodyRect: DOMRect): HTMLElement { + const panel = document.createElement('div') + const body = document.createElement('div') + body.dataset.tabGroupBodyId = groupId + body.dataset.worktreeId = WT + panel.getBoundingClientRect = () => panelRect + body.getBoundingClientRect = () => bodyRect + panel.appendChild(body) + document.body.appendChild(panel) + return panel +} + +function makeDragEvent(activeData: TabDragItemData, pointer: { x: number; y: number }) { + return { + active: { + data: { current: activeData }, + rect: { current: { initial: null } } + }, + over: null, + delta: { x: 0, y: 0 }, + activatorEvent: { clientX: pointer.x, clientY: pointer.y } + } +} + +function renderDragHook(): ReturnType { + let result: ReturnType | null = null + function Probe(): null { + result = useTabDragSplit({ worktreeId: WT }) + return null + } + + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => root.render(createElement(Probe))) + mounted.push({ container, root }) + if (!result) { + throw new Error('useTabDragSplit did not render') + } + return result +} + +beforeEach(() => { + useAppStore.setState({ + activeWorktreeId: WT, + activeGroupIdByWorktree: { [WT]: 'group-1' }, + groupsByWorktree: { + [WT]: [makeGroup('group-1', ['tab-1', 'tab-3']), makeGroup('group-2', ['tab-2'])] + }, + unifiedTabsByWorktree: { + [WT]: [ + { + id: 'tab-1', + groupId: 'group-1', + worktreeId: WT, + contentType: 'terminal', + entityId: 'term-1', + label: 'one', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } satisfies Tab, + { + id: 'tab-2', + groupId: 'group-2', + worktreeId: WT, + contentType: 'terminal', + entityId: 'term-2', + label: 'two', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 1 + } satisfies Tab, + { + id: 'tab-3', + groupId: 'group-1', + worktreeId: WT, + contentType: 'terminal', + entityId: 'term-3', + label: 'three', + customLabel: null, + color: null, + sortOrder: 2, + createdAt: 2 + } satisfies Tab + ] + }, + layoutByWorktree: { + [WT]: { + type: 'split', + direction: 'horizontal', + ratio: 0.5, + first: { type: 'leaf', groupId: 'group-1' }, + second: { type: 'leaf', groupId: 'group-2' } + } satisfies TabGroupLayoutNode + } + }) +}) + +afterEach(() => { + for (const { container, root } of mounted.splice(0)) { + act(() => root.unmount()) + container.remove() + } + document.body.replaceChildren() + vi.clearAllMocks() +}) + describe('canDropTabIntoPaneBody', () => { it('rejects pane-body drops that would split a single tab onto itself', () => { expect( canDropTabIntoPaneBody({ activeDrag: makeDragData('group-1'), - groupsByWorktree: { 'wt-1': [makeGroup('group-1', ['tab-1'])] }, + groupsByWorktree: { [WT]: [makeGroup('group-1', ['tab-1'])] }, overGroupId: 'group-1', - worktreeId: 'wt-1' + worktreeId: WT }) ).toBe(false) }) @@ -40,9 +193,9 @@ describe('canDropTabIntoPaneBody', () => { expect( canDropTabIntoPaneBody({ activeDrag: makeDragData('group-1'), - groupsByWorktree: { 'wt-1': [makeGroup('group-1', ['tab-1', 'tab-2'])] }, + groupsByWorktree: { [WT]: [makeGroup('group-1', ['tab-1', 'tab-2'])] }, overGroupId: 'group-1', - worktreeId: 'wt-1' + worktreeId: WT }) ).toBe(true) }) @@ -52,11 +205,59 @@ describe('canDropTabIntoPaneBody', () => { canDropTabIntoPaneBody({ activeDrag: makeDragData('group-1'), groupsByWorktree: { - 'wt-1': [makeGroup('group-1', ['tab-1']), makeGroup('group-2', ['tab-2'])] + [WT]: [makeGroup('group-1', ['tab-1']), makeGroup('group-2', ['tab-2'])] }, overGroupId: 'group-2', - worktreeId: 'wt-1' + worktreeId: WT }) ).toBe(true) }) + + it('rejects tab-on-tab split drops across groups', () => { + expect( + canDropTabForPaneColumnSplit({ + activeDrag: makeDragData('group-1'), + groupsByWorktree: { + [WT]: [makeGroup('group-1', ['tab-1']), makeGroup('group-2', ['tab-2'])] + }, + targetGroupId: 'group-2', + worktreeId: WT + }) + ).toBe(false) + }) +}) + +describe('useTabDragSplit', () => { + it('commits a geometry-only pane split when drag end has no over target', () => { + addPanelGeometry( + 'group-2', + rect({ left: 500, top: 0, width: 400, height: 600 }), + rect({ left: 500, top: 32, width: 400, height: 568 }) + ) + const activeData = makeDragData('group-1') + const dropUnifiedTab = vi.fn(() => true) + useAppStore.setState({ dropUnifiedTab } as Partial>) + + const drag = renderDragHook() + + act(() => { + drag.onDragStart( + makeDragEvent(activeData, { x: 880, y: 300 }) as unknown as Parameters< + typeof drag.onDragStart + >[0] + ) + }) + act(() => { + drag.onDragEnd( + makeDragEvent(activeData, { x: 880, y: 300 }) as unknown as Parameters< + typeof drag.onDragEnd + >[0] + ) + }) + + expect(dropUnifiedTab).toHaveBeenCalledWith('tab-1', { + groupId: 'group-2', + splitDirection: 'right' + }) + }) }) diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts index 86deb40852d..83d6b598163 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts @@ -1,7 +1,7 @@ /* oxlint-disable max-lines -- Why: the drag-split hook co-locates drop-zone * resolution, same-group reordering, and cross-group handoff so state * transitions stay readable in one place. */ -import { useCallback, useRef, useState } from 'react' +import { useCallback, useRef, useState, type RefObject } from 'react' import { closestCenter, pointerWithin, @@ -16,20 +16,30 @@ import { useSensors } from '@dnd-kit/core' import type { TabGroup, TuiAgent } from '../../../../shared/types' -import type { RuntimeMobileSessionTabMove } from '../../../../shared/runtime-types' import { useAppStore } from '../../store' -import { - isWebRuntimeSessionActive, - moveWebRuntimeSessionTab -} from '../../runtime/web-runtime-session' import type { TabSplitDirection } from '../../store/slices/tabs' +import { mirrorWebRuntimeTabMove } from '../tab-bar/web-runtime-tab-move-mirror' import { resolveTabInsertion, useHoveredTabInsertion, type HoveredTabInsertion } from './tab-insertion' import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + applyDragPreviewTab, + captureTabDragActivationSnapshot, + restoreSourceGroupActiveTabAfterCrossGroupDrop, + restoreTabDragActivationSnapshot, + type TabDragActivationSnapshot +} from './tab-drag-preview-activation' +import { resolveDragPreviewTabId, resolveSourceGroupRestoreOnDrop } from './tab-drag-preview-target' +import { getDragPointer } from './tab-drag-pointer' +import { + captureTabGroupPanelGeometrySnapshot, + resolveActivePaneColumnSplitTarget, + type ActivePaneColumnSplitTarget, + type TabGroupPanelGeometrySnapshot +} from './tab-group-panel-split-target' export type { HoveredTabInsertion } @@ -65,21 +75,7 @@ export type TabPaneDropData = { export type HoveredTabDropTarget = { groupId: string zone: TabDropZone -} - -function mirrorWebRuntimeTabMove( - args: RuntimeMobileSessionTabMove & { - worktreeId: string - } -): void { - const environmentId = getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), args.worktreeId) - if (!isWebRuntimeSessionActive(environmentId)) { - return - } - void moveWebRuntimeSessionTab({ - ...args, - environmentId - }) + panelRect?: DOMRect } export function canDropTabIntoPaneBody({ @@ -113,69 +109,33 @@ export function canDropTabIntoPaneBody({ return true } -function isTabDragData(value: unknown): value is TabDragItemData { +export function canDropTabForPaneColumnSplit(args: { + activeDrag: TabDragItemData | null + groupsByWorktree: Record + targetGroupId: string + worktreeId: string +}): boolean { + if (!args.activeDrag || args.activeDrag.groupId !== args.targetGroupId) { + return false + } + return canDropTabIntoPaneBody({ + activeDrag: args.activeDrag, + groupsByWorktree: args.groupsByWorktree, + overGroupId: args.targetGroupId, + worktreeId: args.worktreeId + }) +} + +export function isTabDragData(value: unknown): value is TabDragItemData { return Boolean(value) && typeof value === 'object' && (value as TabDragItemData).kind === 'tab' } -function isPaneDropData(value: unknown): value is TabPaneDropData { +export function isPaneDropData(value: unknown): value is TabPaneDropData { return ( Boolean(value) && typeof value === 'object' && (value as TabPaneDropData).kind === 'pane-body' ) } -function getDragCenter( - event: Pick -): { x: number; y: number } | null { - const translated = event.active.rect.current.translated - if (translated) { - return { - x: translated.left + translated.width / 2, - y: translated.top + translated.height / 2 - } - } - - const initial = event.active.rect.current.initial - if (!initial) { - return null - } - - return { - x: initial.left + initial.width / 2 + event.delta.x, - y: initial.top + initial.height / 2 + event.delta.y - } -} - -export function resolveDropZone( - rect: { left: number; top: number; width: number; height: number }, - point: { x: number; y: number } -): TabDropZone { - const localX = point.x - rect.left - const localY = point.y - rect.top - const edgeWidthThreshold = rect.width * 0.1 - const edgeHeightThreshold = rect.height * 0.1 - const splitWidthThreshold = rect.width / 3 - - // Why: VS Code keeps a center "merge" zone while biasing side-by-side drops - // toward left/right, which feels much more stable than a generic nearest-edge - // calculation once a workspace has nested splits. - if ( - localX > edgeWidthThreshold && - localX < rect.width - edgeWidthThreshold && - localY > edgeHeightThreshold && - localY < rect.height - edgeHeightThreshold - ) { - return 'center' - } - - if (localX < splitWidthThreshold) { - return 'left' - } - if (localX > splitWidthThreshold * 2) { - return 'right' - } - return localY < rect.height / 2 ? 'up' : 'down' -} - const collisionDetection: CollisionDetection = (args) => { const pointerCollisions = pointerWithin(args) return pointerCollisions.length > 0 ? pointerCollisions : closestCenter(args) @@ -199,6 +159,7 @@ export function useTabDragSplit({ collisionDetection: CollisionDetection hoveredDropTarget: HoveredTabDropTarget | null hoveredTabInsertion: HoveredTabInsertion | null + isTabDragActiveRef: RefObject onDragCancel: () => void onDragEnd: (event: DragEndEvent) => void onDragMove: (event: DragMoveEvent) => void @@ -212,7 +173,12 @@ export function useTabDragSplit({ const [activeDrag, setActiveDrag] = useState(null) const [hoveredDropTarget, setHoveredDropTarget] = useState(null) const releaseWebviewDragPassthroughRef = useRef<(() => void) | null>(null) - const tabInsertion = useHoveredTabInsertion(isTabDragData, getDragCenter) + const preDragActivationSnapshotRef = useRef(null) + const lastPreviewRef = useRef<{ groupId: string; tabId: string | null } | null>(null) + const lastHoveredTabPreviewRef = useRef<{ groupId: string; tabId: string } | null>(null) + const tabDragActiveRef = useRef(false) + const dragGeometryRef = useRef(null) + const tabInsertion = useHoveredTabInsertion(isTabDragData, getDragPointer) // Why: hidden worktrees stay mounted so their PTYs survive worktree // switches, but their DndContext should not activate drags. We use an @@ -250,60 +216,135 @@ export function useTabDragSplit({ ) const clearDragState = useCallback(() => { + tabDragActiveRef.current = false releaseWebviewDragPassthrough() setActiveDrag(null) setHoveredDropTarget(null) tabInsertion.clear() + preDragActivationSnapshotRef.current = null + lastPreviewRef.current = null + lastHoveredTabPreviewRef.current = null + dragGeometryRef.current = null }, [releaseWebviewDragPassthrough, tabInsertion]) - const updateHoveredPane = useCallback( - (event: DragMoveEvent | DragOverEvent) => { - const overData = event.over?.data.current - if (!event.over || !isPaneDropData(overData)) { - // Why: using functional updater to avoid a new null reference when - // the state is already null — prevents unnecessary re-renders during - // high-frequency onDragMove events. - setHoveredDropTarget((prev) => (prev === null ? prev : null)) + const restorePreDragActivation = useCallback(() => { + const snapshot = preDragActivationSnapshotRef.current + if (!snapshot) { + return + } + restoreTabDragActivationSnapshot(worktreeId, snapshot) + }, [worktreeId]) + + const restoreSourceGroupAfterCrossGroupDrop = useCallback( + (activeData: TabDragItemData) => { + const snapshot = preDragActivationSnapshotRef.current + if (!snapshot) { return } - - const activeData = event.active.data.current - if ( - !isTabDragData(activeData) || - !canDropTabIntoPaneBody({ - activeDrag: activeData, - groupsByWorktree: useAppStore.getState().groupsByWorktree, - overGroupId: overData.groupId, - worktreeId - }) - ) { - setHoveredDropTarget((prev) => (prev === null ? prev : null)) - return - } - - const center = getDragCenter(event) - if (!center) { - setHoveredDropTarget((prev) => (prev === null ? prev : null)) - return - } - - // Why: onDragMove fires at pointer-move frequency (~60 fps). Creating - // a new { groupId, zone } object every time would trigger a state - // update and full re-render of the SplitNode tree on every frame even - // when nothing meaningful changed. The functional updater lets us - // compare against the previous value and return the same reference - // when groupId and zone are unchanged. - setHoveredDropTarget((prev) => { - const zone = resolveDropZone(event.over!.rect, center) - if (prev?.groupId === overData.groupId && prev?.zone === zone) { - return prev - } - return { groupId: overData.groupId, zone } + restoreSourceGroupActiveTabAfterCrossGroupDrop({ + worktreeId, + snapshot, + sourceGroupId: activeData.groupId, + movedTabId: activeData.unifiedTabId }) }, [worktreeId] ) + const finishDrag = useCallback( + (restoreSnapshot: boolean, activeData?: TabDragItemData) => { + if (restoreSnapshot) { + restorePreDragActivation() + } else if (activeData) { + restoreSourceGroupAfterCrossGroupDrop(activeData) + } + clearDragState() + }, + [clearDragState, restorePreDragActivation, restoreSourceGroupAfterCrossGroupDrop] + ) + + const updateDragPreviewActivation = useCallback( + (event: DragMoveEvent | DragOverEvent, activeData: TabDragItemData) => { + const snapshot = preDragActivationSnapshotRef.current + if (!snapshot) { + return + } + + const overData = event.over?.data.current + if (isTabDragData(overData) && overData.unifiedTabId !== activeData.unifiedTabId) { + lastHoveredTabPreviewRef.current = { + groupId: overData.groupId, + tabId: overData.unifiedTabId + } + } + + const preview = resolveDragPreviewTabId({ + activeDrag: activeData, + overData, + preDragActiveTabIdByGroup: snapshot.activeTabIdByGroup, + lastHoveredTabPreview: lastHoveredTabPreviewRef.current + }) + const lastPreview = lastPreviewRef.current + if (lastPreview?.groupId === preview.groupId && lastPreview.tabId === preview.tabId) { + return + } + lastPreviewRef.current = preview + applyDragPreviewTab({ + worktreeId, + groupId: preview.groupId, + tabId: preview.tabId, + activeGroupId: preview.groupId + }) + }, + [worktreeId] + ) + + const updateHoveredDropTargetFromSplit = useCallback( + (splitTarget: ActivePaneColumnSplitTarget | null) => { + if (!splitTarget) { + setHoveredDropTarget((prev) => (prev === null ? prev : null)) + return + } + setHoveredDropTarget((prev) => { + if (prev?.groupId === splitTarget.groupId && prev?.zone === splitTarget.zone) { + return prev + } + return { + groupId: splitTarget.groupId, + zone: splitTarget.zone, + panelRect: splitTarget.panelRect + } + }) + }, + [] + ) + + const handleDragUpdate = useCallback( + (event: DragMoveEvent | DragOverEvent) => { + const activeData = event.active.data.current + if (isTabDragData(activeData) && activeData.worktreeId === worktreeId) { + updateDragPreviewActivation(event, activeData) + } + + const state = useAppStore.getState() + const splitTarget = resolveActivePaneColumnSplitTarget({ + event, + groupsByWorktree: state.groupsByWorktree, + layoutByWorktree: state.layoutByWorktree, + worktreeId, + getDragPointer, + geometry: dragGeometryRef.current + }) + updateHoveredDropTargetFromSplit(splitTarget) + if (splitTarget) { + tabInsertion.clear() + } else { + tabInsertion.update(event) + } + }, + [tabInsertion, updateDragPreviewActivation, updateHoveredDropTargetFromSplit, worktreeId] + ) + const onDragStart = useCallback( (event: DragStartEvent) => { const dragData = event.active.data.current @@ -313,6 +354,9 @@ export function useTabDragSplit({ } setActiveDrag(dragData) + tabDragActiveRef.current = true + dragGeometryRef.current = captureTabGroupPanelGeometrySnapshot(worktreeId) + preDragActivationSnapshotRef.current = captureTabDragActivationSnapshot(worktreeId) acquireWebviewDragPassthrough() }, [acquireWebviewDragPassthrough, clearDragState, worktreeId] @@ -320,41 +364,77 @@ export function useTabDragSplit({ const onDragMove = useCallback( (event: DragMoveEvent) => { - updateHoveredPane(event) - tabInsertion.update(event) + handleDragUpdate(event) }, - [updateHoveredPane, tabInsertion] + [handleDragUpdate] ) - const onDragOver = useCallback( - (event: DragOverEvent) => { - updateHoveredPane(event) - tabInsertion.update(event) - }, - [updateHoveredPane, tabInsertion] - ) + const onDragOver = useCallback((_event: DragOverEvent) => { + // Why: onDragMove already carries over + delta; skipping duplicate work here + // avoids running split/insertion resolution twice in the same frame. + }, []) const onDragEnd = useCallback( (event: DragEndEvent) => { const activeData = event.active.data.current const overData = event.over?.data.current + let shouldRestorePreDragActivation = true - if (!event.over || !isTabDragData(activeData) || activeData.worktreeId !== worktreeId) { - clearDragState() + if (!isTabDragData(activeData) || activeData.worktreeId !== worktreeId) { + finishDrag(true) + return + } + + const state = useAppStore.getState() + const paneColumnSplit = resolveActivePaneColumnSplitTarget({ + event, + groupsByWorktree: state.groupsByWorktree, + layoutByWorktree: state.layoutByWorktree, + worktreeId, + getDragPointer, + geometry: dragGeometryRef.current + }) + if (paneColumnSplit) { + const moved = dropUnifiedTab(activeData.unifiedTabId, { + groupId: paneColumnSplit.groupId, + splitDirection: paneColumnSplit.zone + }) + if (moved) { + shouldRestorePreDragActivation = false + mirrorWebRuntimeTabMove({ + kind: 'split', + worktreeId, + tabId: activeData.unifiedTabId, + targetGroupId: paneColumnSplit.groupId, + splitDirection: paneColumnSplit.zone + }) + } + finishDrag( + shouldRestorePreDragActivation, + resolveSourceGroupRestoreOnDrop( + activeData, + paneColumnSplit.groupId, + shouldRestorePreDragActivation + ) + ) + return + } + + if (!event.over) { + finishDrag(true) return } if (isTabDragData(overData)) { if (activeData.unifiedTabId === overData.unifiedTabId) { - clearDragState() + finishDrag(true) return } - const state = useAppStore.getState() const groups = state.groupsByWorktree[worktreeId] ?? [] const targetGroup = groups.find((group) => group.id === overData.groupId) if (!targetGroup) { - clearDragState() + finishDrag(true) return } @@ -362,9 +442,14 @@ export function useTabDragSplit({ // insertion point depends on which side of that tab the cursor sits. // Using the bar's computed side (re-derived here to avoid stale // closures) means the drop always lands where the blue bar was drawn. - const insertion = resolveTabInsertion(event, isTabDragData, getDragCenter) + const insertion = resolveTabInsertion(event, isTabDragData, getDragPointer) + if (!insertion) { + finishDrag(true) + return + } + const overIndex = targetGroup.tabOrder.indexOf(overData.unifiedTabId) - const rawInsertIndex = overIndex + (insertion?.side === 'right' ? 1 : 0) + const rawInsertIndex = overIndex + (insertion.side === 'right' ? 1 : 0) if (activeData.groupId === overData.groupId) { const oldIndex = targetGroup.tabOrder.indexOf(activeData.unifiedTabId) @@ -391,6 +476,7 @@ export function useTabDragSplit({ index }) if (moved) { + shouldRestorePreDragActivation = false mirrorWebRuntimeTabMove({ kind: 'move-to-group', worktreeId, @@ -401,62 +487,46 @@ export function useTabDragSplit({ } } - clearDragState() + finishDrag( + shouldRestorePreDragActivation, + resolveSourceGroupRestoreOnDrop( + activeData, + overData.groupId, + shouldRestorePreDragActivation + ) + ) return } if (isPaneDropData(overData)) { - if ( - !canDropTabIntoPaneBody({ - activeDrag: activeData, - groupsByWorktree: useAppStore.getState().groupsByWorktree, - overGroupId: overData.groupId, - worktreeId + if (activeData.groupId !== overData.groupId) { + const moved = dropUnifiedTab(activeData.unifiedTabId, { + groupId: overData.groupId }) - ) { - clearDragState() - return - } - - const center = getDragCenter(event) - if (center) { - const zone = resolveDropZone(event.over.rect, center) - // Why: a center drop onto the tab's own pane body is a no-op in the - // store (non-split same-group drops are ignored), but - // canDropTabIntoPaneBody still allows it when the source group has - // >1 tab — so the overlay advertises "center" as a valid target. - // Skip the call in that case to avoid misleading the user via a - // drop that silently does nothing. - if (zone !== 'center' || activeData.groupId !== overData.groupId) { - const moved = dropUnifiedTab(activeData.unifiedTabId, { - groupId: overData.groupId, - splitDirection: zone === 'center' ? undefined : zone + if (moved) { + shouldRestorePreDragActivation = false + mirrorWebRuntimeTabMove({ + kind: 'move-to-group', + worktreeId, + tabId: activeData.unifiedTabId, + targetGroupId: overData.groupId }) - if (moved) { - if (zone === 'center') { - mirrorWebRuntimeTabMove({ - kind: 'move-to-group', - worktreeId, - tabId: activeData.unifiedTabId, - targetGroupId: overData.groupId - }) - } else { - mirrorWebRuntimeTabMove({ - kind: 'split', - worktreeId, - tabId: activeData.unifiedTabId, - targetGroupId: overData.groupId, - splitDirection: zone - }) - } - } } } } - clearDragState() + finishDrag( + shouldRestorePreDragActivation, + isPaneDropData(overData) + ? resolveSourceGroupRestoreOnDrop( + activeData, + overData.groupId, + shouldRestorePreDragActivation + ) + : undefined + ) }, - [clearDragState, dropUnifiedTab, reorderUnifiedTabs, worktreeId] + [dropUnifiedTab, finishDrag, reorderUnifiedTabs, worktreeId] ) // Why: dnd-kit fires onDragCancel (not onDragEnd) when the user presses @@ -464,14 +534,15 @@ export function useTabDragSplit({ // activeDrag and hoveredDropTarget state would remain stale, leaving the // drop overlay visible indefinitely. const onDragCancel = useCallback(() => { - clearDragState() - }, [clearDragState]) + finishDrag(true) + }, [finishDrag]) return { activeDrag, collisionDetection, hoveredDropTarget, hoveredTabInsertion: tabInsertion.hoveredTabInsertion, + isTabDragActiveRef: tabDragActiveRef, onDragCancel, onDragEnd, onDragMove, diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts index d5d0c908717..be86887517d 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts @@ -214,11 +214,54 @@ describe('useTabGroupWorkspaceModel terminal activation focus', () => { expect(mocks.createEmptySplitGroup).toHaveBeenCalledWith('wt-1', 'group-1', 'right') expect(mocks.createTab).toHaveBeenCalledWith('wt-1', 'group-2') + expect(mocks.dropUnifiedTab).not.toHaveBeenCalled() expect(mocks.recordFeatureInteraction).toHaveBeenCalledWith('terminal-pane-split') expect(mocks.setActiveTab).toHaveBeenCalledWith('terminal-2') expect(mocks.setActiveTabType).toHaveBeenCalledWith('terminal') }) + it('seeds a new terminal instead of moving the active tab when the group has multiple tabs', async () => { + const secondUnifiedTab = { + id: 'unified-terminal-2', + entityId: 'terminal-2', + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'terminal', + label: 'Terminal 2', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 1 + } + storeBox.state = { + ...storeBox.state, + groupsByWorktree: { + 'wt-1': [ + { + id: 'group-1', + worktreeId: 'wt-1', + activeTabId: secondUnifiedTab.id, + tabOrder: ['unified-terminal-1', secondUnifiedTab.id] + } + ] + }, + unifiedTabsByWorktree: { + 'wt-1': [...(storeBox.state?.unifiedTabsByWorktree?.['wt-1'] ?? []), secondUnifiedTab] + } + } + mocks.createEmptySplitGroup.mockReturnValue('group-2') + mocks.createTab.mockReturnValue({ id: 'terminal-3' }) + const { useTabGroupWorkspaceModel } = await import('./useTabGroupWorkspaceModel') + const model = useTabGroupWorkspaceModel({ groupId: 'group-1', worktreeId: 'wt-1' }) + + model.commands.createSplitGroup('right') + + expect(mocks.createEmptySplitGroup).toHaveBeenCalledWith('wt-1', 'group-1', 'right') + expect(mocks.createTab).toHaveBeenCalledWith('wt-1', 'group-2') + expect(mocks.dropUnifiedTab).not.toHaveBeenCalled() + expect(mocks.setActiveTab).toHaveBeenCalledWith('terminal-3') + }) + it('closes client-local browser fallback tabs locally in remote workspaces', async () => { mocks.isWebRuntimeSessionActive.mockReturnValue(true) const browserTab = { diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index 5cc355b55ac..a723a457667 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -97,7 +97,6 @@ export function useTabGroupWorkspaceModel({ const closeBrowserTab = useAppStore((state) => state.closeBrowserTab) const setActiveBrowserTab = useAppStore((state) => state.setActiveBrowserTab) const setActiveWorktree = useAppStore((state) => state.setActiveWorktree) - const dropUnifiedTab = useAppStore((state) => state.dropUnifiedTab) const createEmptySplitGroup = useAppStore((state) => state.createEmptySplitGroup) const setTabCustomTitle = useAppStore((state) => state.setTabCustomTitle) const setTabColor = useAppStore((state) => state.setTabColor) @@ -456,52 +455,25 @@ export function useTabGroupWorkspaceModel({ ) const createSplitGroup = useCallback( - (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId?: string) => { - const sourceTab = - groupTabs.find((candidate) => - candidate.contentType === 'terminal' || candidate.contentType === 'browser' - ? candidate.entityId === sourceVisibleTabId - : candidate.id === sourceVisibleTabId - ) ?? activeTab - + (direction: 'left' | 'right' | 'up' | 'down') => { focusGroup(worktreeId, groupId) - if (!sourceTab) { + const newGroupId = createEmptySplitGroup(worktreeId, groupId, direction) + if (!newGroupId) { return } - - // Why: for terminals specifically, splitting a single-tab group should - // still produce a useful split — spawn a fresh terminal in the new pane - // and leave the existing one behind. Moving the only tab would collapse - // the split immediately (see the same-group guard in dropUnifiedTab), - // giving the user nothing; a new terminal preserves the old shortcut - // flow without duplicating a persistent tab like editors/browsers would. - if (sourceTab.contentType === 'terminal' && groupTabs.length <= 1) { - const newGroupId = createEmptySplitGroup(worktreeId, groupId, direction) - if (!newGroupId) { - return - } - const terminal = createTab(worktreeId, newGroupId) - recordTerminalTabGroupSplit(terminal) - setActiveTab(terminal.id) - setActiveTabType('terminal') - return - } - - // Why: split actions MOVE the source tab into the new pane rather than - // leaving a duplicate in the origin. Delegating to dropUnifiedTab reuses - // the same split+move path as drag-to-split so keyboard/menu splits and - // drag splits stay behaviorally identical, including collapsing the - // origin group if its last tab is the one we just moved. - dropUnifiedTab(sourceTab.id, { groupId, splitDirection: direction }) + // Why: the tab-strip Split pane control adds a split pane to the right of + // the group that owns the button. Dragging tabs can still open other + // directions; this entry point always seeds a fresh terminal. + const terminal = createTab(worktreeId, newGroupId) + recordTerminalTabGroupSplit(terminal) + setActiveTab(terminal.id) + setActiveTabType('terminal') }, [ - activeTab, createEmptySplitGroup, createTab, - dropUnifiedTab, focusGroup, groupId, - groupTabs, setActiveTab, setActiveTabType, worktreeId diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index c5f4c005b81..e68821654bf 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -3,11 +3,8 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { createPortal } from 'react-dom' import type { CSSProperties } from 'react' import type { IDisposable } from '@xterm/xterm' -import { X } from 'lucide-react' import { useAppStore } from '../../store' import { isUnifiedTabPinned } from '@/store/pinned-tab-close-guard' -import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useLinkRoutingPreferenceDialog } from '@/components/link-routing-preference-dialog' import { DaemonActionDialog, useDaemonActions } from '@/components/shared/useDaemonActions' import { @@ -41,6 +38,8 @@ import { MobileDriverOverlay } from './MobileDriverOverlay' import { TerminalErrorToast } from './TerminalErrorToast' import { TerminalSessionStateSaveFailureDialog } from './TerminalSessionStateSaveFailureDialog' import TerminalContextMenu from './TerminalContextMenu' +import TerminalPaneHeaderOverlay from './TerminalPaneHeaderOverlay' +import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd' import { TerminalAgentSessionForkDialog } from './TerminalAgentSessionForkDialog' import { SessionRestoredBannerPortals } from './SessionRestoredBannerPortals' import { useSessionRestoredBannerDismiss } from './useSessionRestoredBannerDismiss' @@ -140,7 +139,6 @@ import { import { getCachedTerminalTabForWorktree } from './terminal-tab-lookup' import { getCachedTerminalGroupIdForWorktree } from './terminal-unified-tab-lookup' import { useRepoById } from '@/store/selectors' -import { translate } from '@/i18n/i18n' type TerminalPaneProps = { tabId: string @@ -1764,12 +1762,22 @@ export default function TerminalPane({ panes: manager.getPanes(), paneTitles, renamingPaneId, - sessionRestoredBannerPaneIds + sessionRestoredBannerPaneIds, + reservePaneHeaderSpace: isActive && (isVisible || shouldMeasureHiddenStartup) }) if (needsFit) { fitPanes(manager) } - }, [paneCount, paneLayoutRevision, paneTitles, renamingPaneId, sessionRestoredBannerPaneIds]) + }, [ + paneCount, + paneLayoutRevision, + paneTitles, + renamingPaneId, + sessionRestoredBannerPaneIds, + isActive, + isVisible, + shouldMeasureHiddenStartup + ]) const syncPaneTitleOverlayRects = useCallback((): void => { const manager = managerRef.current @@ -2300,6 +2308,33 @@ export default function TerminalPane({ managerRef.current?.setActivePane(paneId, { focus: false }) }, []) + const splitTerminalPaneFromHeader = useCallback( + (pane: ManagedPane, direction: 'vertical' | 'horizontal') => { + const manager = managerRef.current + if (!manager) { + return + } + splitTerminalPaneWithInheritedCwd({ + manager, + getManager: () => managerRef.current, + paneTransports: paneTransportsRef.current, + paneCwdMap: paneCwdRef.current, + fallbackCwd: cwd ?? '', + pane, + direction, + source: 'context_menu' + }) + }, + [cwd] + ) + + const beginPaneDragFromHeader = useCallback( + (paneId: number, handle: HTMLElement, event: PointerEvent) => { + managerRef.current?.beginPaneDragFromPointerDown(paneId, handle, event) + }, + [] + ) + const effectiveAppearance = settings ? resolveEffectiveTerminalAppearance(settings, systemPrefersDark) : null @@ -2339,13 +2374,13 @@ export default function TerminalPane({ } const activePane = managerRef.current?.getActivePane() + const managedPanes = managerRef.current?.getPanes() ?? [] return ( <>
- {/* Title bars live in Orca-owned React DOM rather than xterm's pane - subtree. The pane still reserves title height for terminal layout, - while editor controls stay outside xterm's helper textarea focus zone. */} -
- {(managerRef.current?.getPanes() ?? []).map((pane) => { - const title = paneTitles[pane.id] - const isEditing = renamingPaneId === pane.id - const overlayRect = paneTitleOverlayRects[pane.id] - if ((!title && !isEditing) || !overlayRect) { - return null - } - return ( -
activatePaneTitleInteraction(pane.id)} - onDragOver={(event) => { - activatePaneTitleInteraction(pane.id) - if ( - event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME) || - event.dataTransfer.types.includes(WORKSPACE_FILE_PATHS_MIME) - ) { - event.preventDefault() - event.dataTransfer.dropEffect = 'copy' - } - }} - onDrop={(event) => { - if ( - !event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME) && - !event.dataTransfer.types.includes(WORKSPACE_FILE_PATHS_MIME) - ) { - return - } - event.preventDefault() - event.stopPropagation() - activatePaneTitleInteraction(pane.id) - const manager = managerRef.current - if (!manager) { - return - } - void handleInternalTerminalFileDrop({ - manager, - paneTransports: paneTransportsRef.current, - worktreeId, - tabId, - cwd, - dataTransfer: event.dataTransfer, - dropTarget: event.target - }) - }} - onContextMenuCapture={(event) => contextMenu.onPaneTitleContextMenu(event, pane.id)} - style={{ - left: overlayRect.left, - top: overlayRect.top, - width: overlayRect.width - }} - > - {isEditing ? ( - setRenameValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleRenameSubmit() - } else if (e.key === 'Escape') { - handleRenameCancel() - } - }} - onBlur={handleRenameBlur} - /> - ) : ( - <> - {paneCount > 1 && ( - - ) - })} -
- {(managerRef.current?.getPanes() ?? []).map((pane) => { + + {managedPanes.map((pane) => { // Why: pane IDs can collide across tabs (e.g. tab 0 pane 1 and tab 1 // pane 1). Using the transport's actual ptyId avoids showing banners // on the wrong pane when IDs overlap. diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx new file mode 100644 index 00000000000..6178db32516 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx @@ -0,0 +1,143 @@ +/** + * @vitest-environment happy-dom + */ +import { act, createRef, type ReactNode, type RefObject } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' +import type { PtyTransport } from './pty-transport' +import TerminalPaneHeaderOverlay from './TerminalPaneHeaderOverlay' + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children?: ReactNode }) => children, + TooltipTrigger: ({ children }: { children?: ReactNode }) => children, + TooltipContent: ({ children }: { children?: ReactNode }) => {children} +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + Object.entries(values ?? {}).reduce( + (text, [key, value]) => text.replace(`{{${key}}}`, value), + fallback + ) +})) + +const mounted: { container: HTMLDivElement; root: Root }[] = [] + +function makePane(id: number): ManagedPane { + const leafId = `leaf-${id}` as ManagedPane['leafId'] + return { + id, + leafId, + stablePaneId: leafId, + container: document.createElement('div'), + linkTooltip: document.createElement('div'), + terminal: {} as ManagedPane['terminal'], + fitAddon: {} as ManagedPane['fitAddon'], + searchAddon: {} as ManagedPane['searchAddon'], + serializeAddon: {} as ManagedPane['serializeAddon'] + } +} + +function renderOverlay({ + paneTitles, + paneCount = 2, + showAlwaysOnHeaders = true, + onClosePane = vi.fn(), + onRemoveTitle = vi.fn() +}: { + paneTitles: Record + paneCount?: number + showAlwaysOnHeaders?: boolean + onClosePane?: ReturnType + onRemoveTitle?: ReturnType +}): { + container: HTMLDivElement + onClosePane: ReturnType + onRemoveTitle: ReturnType +} { + const panes = [makePane(1), makePane(2)] + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render( + ()} + titleUsesLightSurface={false} + paneTitleBackground="transparent" + terminalContentVisible + hiddenStartupStyle={{}} + managerRef={{ current: null } as RefObject} + paneTransportsRef={{ current: new Map() } as RefObject>} + onSplitPane={vi.fn()} + onBeginPaneDrag={vi.fn()} + onActivatePaneTitleInteraction={vi.fn()} + onPaneTitleContextMenu={vi.fn()} + onStartRename={vi.fn()} + onRemoveTitle={onRemoveTitle as (paneId: number) => void} + onClosePane={onClosePane as (paneId: number) => void} + onRenameValueChange={vi.fn()} + onRenameSubmit={vi.fn()} + onRenameCancel={vi.fn()} + onRenameBlur={vi.fn()} + /> + ) + }) + mounted.push({ container, root }) + return { container, onClosePane, onRemoveTitle } +} + +afterEach(() => { + for (const { container, root } of mounted.splice(0)) { + act(() => root.unmount()) + container.remove() + } +}) + +describe('TerminalPaneHeaderOverlay', () => { + it('keeps the titled-pane close affordance as remove-title while headers are always on', () => { + const { container, onClosePane, onRemoveTitle } = renderOverlay({ + paneTitles: { 1: 'server', 2: '' } + }) + + const removeTitle = container.querySelector( + 'button[aria-label="Remove pane title: server"]' + ) + expect(removeTitle).not.toBeNull() + + act(() => removeTitle?.click()) + + expect(onRemoveTitle).toHaveBeenCalledWith(1) + expect(onClosePane).not.toHaveBeenCalledWith(1) + }) + + it('keeps close-pane available for untitled split pane headers', () => { + const { container, onClosePane, onRemoveTitle } = renderOverlay({ + paneTitles: { 1: '', 2: '' } + }) + + const closePane = container.querySelector('button[aria-label="Close Pane"]') + expect(closePane).not.toBeNull() + + act(() => closePane?.click()) + + expect(onClosePane).toHaveBeenCalledWith(1) + expect(onRemoveTitle).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx new file mode 100644 index 00000000000..ca10b1ac38b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx @@ -0,0 +1,298 @@ +import type { CSSProperties, RefObject } from 'react' +import { SquareSplitVertical, X } from 'lucide-react' +import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag' +import type { PtyTransport } from './pty-transport' +import { handleInternalTerminalFileDrop } from './terminal-drop-handler' + +export type PaneTitleOverlayRect = { + left: number + top: number + width: number +} + +type TerminalPaneHeaderOverlayProps = { + tabId: string + worktreeId: string + cwd: string + showAlwaysOnHeaders: boolean + paneCount: number + activePaneId: number | null | undefined + panes: readonly ManagedPane[] + paneTitles: Readonly> + paneTitleOverlayRects: Readonly> + renamingPaneId: number | null + renameValue: string + renameInputRef: RefObject + titleUsesLightSurface: boolean + paneTitleBackground: string + terminalContentVisible: boolean + hiddenStartupStyle: CSSProperties + managerRef: RefObject + paneTransportsRef: RefObject> + onSplitPane: (pane: ManagedPane, direction: 'vertical' | 'horizontal') => void + onBeginPaneDrag: (paneId: number, handle: HTMLElement, event: PointerEvent) => void + onActivatePaneTitleInteraction: (paneId: number) => void + onPaneTitleContextMenu: (event: React.MouseEvent, paneId: number) => void + onStartRename: (paneId: number) => void + onRemoveTitle: (paneId: number) => void + onClosePane: (paneId: number) => void + onRenameValueChange: (value: string) => void + onRenameSubmit: () => void + onRenameCancel: () => void + onRenameBlur: () => void +} + +export default function TerminalPaneHeaderOverlay({ + tabId, + worktreeId, + cwd, + showAlwaysOnHeaders, + paneCount, + activePaneId, + panes, + paneTitles, + paneTitleOverlayRects, + renamingPaneId, + renameValue, + renameInputRef, + titleUsesLightSurface, + paneTitleBackground, + terminalContentVisible, + hiddenStartupStyle, + managerRef, + paneTransportsRef, + onSplitPane, + onBeginPaneDrag, + onActivatePaneTitleInteraction, + onPaneTitleContextMenu, + onStartRename, + onRemoveTitle, + onClosePane, + onRenameValueChange, + onRenameSubmit, + onRenameCancel, + onRenameBlur +}: TerminalPaneHeaderOverlayProps): React.JSX.Element { + const splitRightLabel = translate( + 'auto.components.terminal.pane.TerminalContextMenu.20e565d865', + 'Split Terminal Right' + ) + + return ( +
+ {panes.map((pane) => { + const title = paneTitles[pane.id] + const isEditing = renamingPaneId === pane.id + const overlayRect = paneTitleOverlayRects[pane.id] + const isActivePane = activePaneId === pane.id + const isChromeless = showAlwaysOnHeaders && !title && !isEditing + const showHeader = overlayRect && (showAlwaysOnHeaders || Boolean(title) || isEditing) + if (!showHeader || !overlayRect) { + return null + } + + return ( +
onActivatePaneTitleInteraction(pane.id) : undefined + } + onDragOver={(event) => { + onActivatePaneTitleInteraction(pane.id) + if ( + event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME) || + event.dataTransfer.types.includes(WORKSPACE_FILE_PATHS_MIME) + ) { + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + } + }} + onDrop={(event) => { + if ( + !event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME) && + !event.dataTransfer.types.includes(WORKSPACE_FILE_PATHS_MIME) + ) { + return + } + event.preventDefault() + event.stopPropagation() + onActivatePaneTitleInteraction(pane.id) + const manager = managerRef.current + if (!manager) { + return + } + void handleInternalTerminalFileDrop({ + manager, + paneTransports: paneTransportsRef.current, + worktreeId, + tabId, + cwd, + dataTransfer: event.dataTransfer, + dropTarget: event.target + }) + }} + onContextMenuCapture={(event) => onPaneTitleContextMenu(event, pane.id)} + style={{ + left: overlayRect.left, + top: overlayRect.top, + width: overlayRect.width + }} + > + {isEditing ? ( + onRenameValueChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + onRenameSubmit() + } else if (event.key === 'Escape') { + onRenameCancel() + } + }} + onBlur={onRenameBlur} + /> + ) : ( + <> + {paneCount > 1 && ( + + ) + })} +
+ ) +} diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index 6b54e46875c..7ea67c0d99b 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -12,13 +12,13 @@ import { type KeybindingPlatform, type TerminalShortcutPolicy } from '../../../../shared/keybindings' -import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd' +import { type PaneCwdMap } from './resolve-split-cwd' import { keyboardEventBelongsToScope } from './terminal-keyboard-scope' import { normalizeSelectedTextForFileSearch } from '@/lib/file-search-selection' import { isFindQueryTooLarge } from '@/lib/find-query-bounds' -import { splitWebRuntimeTerminal } from '@/runtime/web-runtime-session' import { handleEmptyFloatingWorkspacePanelCloseShortcut } from '@/lib/floating-workspace-terminal-actions' import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' +import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd' import { useAppStore } from '@/store' import { recordTerminalUserInputForLeaf } from './terminal-input-activity' @@ -411,41 +411,16 @@ export function useTerminalKeyboardShortcuts({ if (!pane) { return } - const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null - const telemetrySource = getKeyboardSplitTelemetrySource() - if (splitWebRuntimeTerminal(ptyId, action.direction, telemetrySource)) { - return - } - // Split-pane CWD inheritance (docs/ssh-split-pane-inherit-cwd.md): - // if we have a confirmed live OSC 7 for the source pane, split - // synchronously to preserve chaining on rapid Cmd+D. Otherwise fall - // back to an async resolve that queries pty.getCwd. - const cached = paneCwdRef.current.get(pane.id) - if (cached?.confirmed && cached.cwd) { - const createdPane = manager.splitPane(pane.id, action.direction, { cwd: cached.cwd }) - recordKeyboardCreatedTerminalPaneSplit(createdPane, { - source: telemetrySource, - direction: action.direction - }) - return - } - const paneIdAtDispatch = pane.id - const directionAtDispatch = action.direction - void (async () => { - const cwd = await resolveSplitCwd({ - paneCwdMap: paneCwdRef.current, - sourcePaneId: paneIdAtDispatch, - sourcePtyId: ptyId, - fallbackCwd - }) - const createdPane = managerRef.current?.splitPane(paneIdAtDispatch, directionAtDispatch, { - cwd - }) - recordKeyboardCreatedTerminalPaneSplit(createdPane, { - source: telemetrySource, - direction: directionAtDispatch - }) - })() + splitTerminalPaneWithInheritedCwd({ + manager, + getManager: () => managerRef.current, + paneTransports: paneTransportsRef.current, + paneCwdMap: paneCwdRef.current, + fallbackCwd, + pane, + direction: action.direction, + source: getKeyboardSplitTelemetrySource() + }) } } diff --git a/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx b/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx index db8a13faada..1683b4fa5e5 100644 --- a/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx +++ b/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx @@ -76,6 +76,23 @@ describe('session restored banner pane state', () => { expect(paneText(createdPane)).toBe(SESSION_RESTORED_BANNER_TEXT) }) + it('reserves title space for always-on pane headers on the active tab', () => { + const activePane = createPane(1) + const secondPane = createPane(2) + + const needsFit = syncSessionRestoredBannerTitleSpace({ + panes: [activePane, secondPane], + paneTitles: {}, + renamingPaneId: null, + sessionRestoredBannerPaneIds: new Set(), + reservePaneHeaderSpace: true + }) + + expect(needsFit).toBe(true) + expect(activePane.container.hasAttribute('data-has-title')).toBe(true) + expect(secondPane.container.hasAttribute('data-has-title')).toBe(true) + }) + it('renders and reserves title space only on the restored inactive split pane', async () => { const activePane = createPane(1) const inactiveRestoredPane = createPane(2) diff --git a/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.ts b/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.ts index 03fd87ad600..969225d8365 100644 --- a/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.ts +++ b/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.ts @@ -88,10 +88,12 @@ export function syncSessionRestoredBannerTitleSpace(args: { paneTitles: Readonly> renamingPaneId: number | null sessionRestoredBannerPaneIds: ReadonlySet + reservePaneHeaderSpace?: boolean }): boolean { let needsFit = false for (const pane of args.panes) { const shouldShow = + args.reservePaneHeaderSpace === true || !!args.paneTitles[pane.id] || args.renamingPaneId === pane.id || args.sessionRestoredBannerPaneIds.has(pane.id) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts new file mode 100644 index 00000000000..53155fa786f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' +import type { PtyTransport } from './pty-transport' +import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd' + +const mocks = vi.hoisted(() => ({ + recordCreatedTerminalPaneSplit: vi.fn(), + resolveSplitCwd: vi.fn(), + splitWebRuntimeTerminal: vi.fn() +})) + +vi.mock('@/runtime/web-runtime-session', () => ({ + splitWebRuntimeTerminal: mocks.splitWebRuntimeTerminal +})) + +vi.mock('./resolve-split-cwd', () => ({ + resolveSplitCwd: mocks.resolveSplitCwd +})) + +vi.mock('./terminal-pane-split-completion', () => ({ + recordCreatedTerminalPaneSplit: mocks.recordCreatedTerminalPaneSplit +})) + +function makeManager(splitPane: ReturnType): PaneManager { + return { splitPane } as unknown as PaneManager +} + +async function flushAsyncSplit(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +describe('splitTerminalPaneWithInheritedCwd', () => { + beforeEach(() => { + mocks.recordCreatedTerminalPaneSplit.mockReset() + mocks.resolveSplitCwd.mockReset() + mocks.splitWebRuntimeTerminal.mockReset() + mocks.splitWebRuntimeTerminal.mockReturnValue(false) + }) + + it('uses the live manager after async cwd resolution', async () => { + const staleSplitPane = vi.fn() + const liveSplitPane = vi.fn(() => ({ id: 2 })) + mocks.resolveSplitCwd.mockResolvedValue('/resolved') + + splitTerminalPaneWithInheritedCwd({ + manager: makeManager(staleSplitPane), + getManager: () => makeManager(liveSplitPane), + paneTransports: new Map(), + paneCwdMap: new Map(), + fallbackCwd: '/fallback', + pane: { id: 1 } as ManagedPane, + direction: 'vertical', + source: 'context_menu' + }) + + await flushAsyncSplit() + + expect(staleSplitPane).not.toHaveBeenCalled() + expect(liveSplitPane).toHaveBeenCalledWith(1, 'vertical', { cwd: '/resolved' }) + expect(mocks.recordCreatedTerminalPaneSplit).toHaveBeenCalledWith( + { id: 2 }, + { source: 'context_menu', direction: 'vertical' } + ) + }) + + it('does not split a stale manager when the live manager is gone', async () => { + const staleSplitPane = vi.fn() + mocks.resolveSplitCwd.mockResolvedValue('/resolved') + + splitTerminalPaneWithInheritedCwd({ + manager: makeManager(staleSplitPane), + getManager: () => null, + paneTransports: new Map(), + paneCwdMap: new Map(), + fallbackCwd: '/fallback', + pane: { id: 1 } as ManagedPane, + direction: 'horizontal', + source: 'context_menu' + }) + + await flushAsyncSplit() + + expect(staleSplitPane).not.toHaveBeenCalled() + expect(mocks.recordCreatedTerminalPaneSplit).toHaveBeenCalledWith(undefined, { + source: 'context_menu', + direction: 'horizontal' + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts new file mode 100644 index 00000000000..5fefdb78edd --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts @@ -0,0 +1,47 @@ +import type { TerminalPaneSplitSource } from '../../../../shared/feature-education-telemetry' +import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' +import { splitWebRuntimeTerminal } from '@/runtime/web-runtime-session' +import type { PtyTransport } from './pty-transport' +import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd' +import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' + +export function splitTerminalPaneWithInheritedCwd(args: { + manager: PaneManager + getManager?: () => PaneManager | null + paneTransports: Map + paneCwdMap: PaneCwdMap + fallbackCwd: string + pane: ManagedPane + direction: 'vertical' | 'horizontal' + source: TerminalPaneSplitSource +}): void { + const ptyId = args.paneTransports.get(args.pane.id)?.getPtyId() ?? null + if (splitWebRuntimeTerminal(ptyId, args.direction, args.source)) { + return + } + const cached = args.paneCwdMap.get(args.pane.id) + if (cached?.confirmed && cached.cwd) { + const createdPane = args.manager.splitPane(args.pane.id, args.direction, { cwd: cached.cwd }) + recordCreatedTerminalPaneSplit(createdPane, { + source: args.source, + direction: args.direction + }) + return + } + const paneId = args.pane.id + const resolveManager = (): PaneManager | null => + args.getManager ? args.getManager() : args.manager + void (async () => { + const cwd = await resolveSplitCwd({ + paneCwdMap: args.paneCwdMap, + sourcePaneId: paneId, + sourcePtyId: ptyId, + fallbackCwd: args.fallbackCwd + }) + const createdPane = resolveManager()?.splitPane(paneId, args.direction, { cwd }) + recordCreatedTerminalPaneSplit(createdPane, { + source: args.source, + direction: args.direction + }) + })() +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 6762282fae0..0fc54a533f2 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -5,11 +5,10 @@ import { toast } from 'sonner' import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import type { PtyTransport } from './pty-transport' import { getConnectionId } from '@/lib/connection-context' -import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd' +import type { PaneCwdMap } from './resolve-split-cwd' import type { TerminalQuickCommand } from '../../../../shared/types' import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands' import { sendTerminalQuickCommandToPane } from './terminal-quick-command-dispatch' -import { splitWebRuntimeTerminal } from '@/runtime/web-runtime-session' import { pasteTerminalText } from './terminal-bracketed-paste' import { pasteTerminalClipboard } from './terminal-clipboard-paste' import { @@ -35,6 +34,7 @@ import { type PreparedAgentSessionFork } from './terminal-agent-session-fork' import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' +import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { recordTerminalUserInputForLeaf } from './terminal-input-activity' @@ -310,39 +310,26 @@ export function useTerminalPaneContextMenu({ const onPaste = async (): Promise => pasteResolvedPane('context-menu') - // Split-pane CWD inheritance (docs/ssh-split-pane-inherit-cwd.md): - // mirror the Cmd+D path — sync split on confirmed OSC 7 cache hit, - // otherwise fall back to async resolveSplitCwd. const splitWithInheritedCwd = useCallback( ( direction: 'vertical' | 'horizontal', source: 'contextual_tour' | 'context_menu' = 'context_menu' ): void => { const pane = resolveMenuPane() - if (!pane) { + const manager = managerRef.current + if (!pane || !manager) { return } - const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null - if (splitWebRuntimeTerminal(ptyId, direction, source)) { - return - } - const cached = paneCwdRef.current.get(pane.id) - if (cached?.confirmed && cached.cwd) { - const createdPane = managerRef.current?.splitPane(pane.id, direction, { cwd: cached.cwd }) - recordContextMenuCreatedTerminalPaneSplit(createdPane, { source, direction }) - return - } - const paneId = pane.id - void (async () => { - const cwd = await resolveSplitCwd({ - paneCwdMap: paneCwdRef.current, - sourcePaneId: paneId, - sourcePtyId: ptyId, - fallbackCwd - }) - const createdPane = managerRef.current?.splitPane(paneId, direction, { cwd }) - recordContextMenuCreatedTerminalPaneSplit(createdPane, { source, direction }) - })() + splitTerminalPaneWithInheritedCwd({ + manager, + getManager: () => managerRef.current, + paneTransports: paneTransportsRef.current, + paneCwdMap: paneCwdRef.current, + fallbackCwd, + pane, + direction, + source + }) }, [fallbackCwd, managerRef, paneCwdRef, paneTransportsRef, resolveMenuPane] ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 05d8a111b76..91f9a7a7aea 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2436,7 +2436,9 @@ "9acaf92093": "Pane Actions", "1bce81dba6": "simulator", "1ff1c77616": "browser", - "586d2ac445": "terminal" + "586d2ac445": "terminal", + "addSplitPane": "Add split pane", + "closePaneColumn": "Close split pane" }, "AiVaultSessionDropLayer": { "dropOntoTerminalPane": "Drop onto a terminal pane to resume this session.", @@ -2444,6 +2446,9 @@ "localWorkspacesOnly": "Resume from history is only available in local workspaces.", "openLocalWorkspace": "Open a local workspace before resuming a session.", "sessionQueued": "Session queued" + }, + "TabGroupDropOverlay": { + "paneColumnLabel": "New split" } }, "bar": { @@ -2514,7 +2519,9 @@ "cb3eadefd2": "Blue", "20baa43c05": "None", "60f958ec75": "Pin Tab", - "417722e9c2": "Unpin Tab" + "417722e9c2": "Unpin Tab", + "splitTerminalRight": "Split terminal right", + "splitTerminalDown": "Split terminal down" }, "TabBar": { "b1a132357f": "New tab", @@ -2606,6 +2613,13 @@ } } } + }, + "TabWorkspaceLayoutMenuSection": { + "right": "Right", + "left": "Left", + "down": "Down", + "up": "Up", + "moveToPaneColumn": "Move Tab to Split" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 79012408eea..159eeebe980 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2436,7 +2436,9 @@ "9acaf92093": "Acciones del panel", "1bce81dba6": "simulador", "1ff1c77616": "navegador", - "586d2ac445": "terminal" + "586d2ac445": "terminal", + "addSplitPane": "Agregar panel dividido", + "closePaneColumn": "Cerrar panel dividido" }, "AiVaultSessionDropLayer": { "dropOntoTerminalPane": "Drop onto a terminal pane to resume this session.", @@ -2444,6 +2446,9 @@ "localWorkspacesOnly": "Resume from history is only available in local workspaces.", "openLocalWorkspace": "Open a local workspace before resuming a session.", "sessionQueued": "Session queued" + }, + "TabGroupDropOverlay": { + "paneColumnLabel": "Nueva división" } }, "bar": { @@ -2514,7 +2519,9 @@ "cb3eadefd2": "Azul", "20baa43c05": "Ninguno", "60f958ec75": "Pestaña Fijar", - "417722e9c2": "Desanclar pestaña" + "417722e9c2": "Desanclar pestaña", + "splitTerminalRight": "Dividir terminal a la derecha", + "splitTerminalDown": "Dividir terminal hacia abajo" }, "TabBar": { "b1a132357f": "Nueva pestaña", @@ -2606,6 +2613,13 @@ } } } + }, + "TabWorkspaceLayoutMenuSection": { + "right": "Derecha", + "left": "Izquierda", + "down": "Abajo", + "up": "Arriba", + "moveToPaneColumn": "Mover pestaña a división" } } }, @@ -9127,15 +9141,15 @@ "monthsAgo": "{{value0}}mo ago", "yearsAgo": "{{value0}}y ago", "sessionId": "ID de sesión", - "originalAsk": "Original ask", - "latestTurns": "Latest turns", - "noPreviewAvailable": "No conversation preview available", - "messageCount": "{{value0}} msgs", - "userRole": "You", - "agentRole": "Agent", - "toolRole": "Tool", - "systemRole": "System", - "sessionRole": "Session" + "originalAsk": "Solicitud original", + "latestTurns": "Últimos turnos", + "noPreviewAvailable": "No hay vista previa de conversación disponible", + "messageCount": "{{value0}} mensajes", + "userRole": "Tú", + "agentRole": "Agente", + "toolRole": "Herramienta", + "systemRole": "Sistema", + "sessionRole": "Sesión" }, "AiVaultSessionRow": { "resumeAgentSession": "Resume {{value0}} session", @@ -9153,13 +9167,13 @@ "showDetails": "Mostrar detalles", "moreSessionActions": "Más acciones de sesión", "moreActions": "Más acciones", - "noPreviewAvailable": "No conversation preview available", - "dragToResume": "Drag to resume in a new tab", - "userRole": "You", - "agentRole": "Agent", - "toolRole": "Tool", - "systemRole": "System", - "sessionRole": "Session" + "noPreviewAvailable": "No hay vista previa de conversación disponible", + "dragToResume": "Arrastra para reanudar en una pestaña nueva", + "userRole": "Tú", + "agentRole": "Agente", + "toolRole": "Herramienta", + "systemRole": "Sistema", + "sessionRole": "Sesión" }, "FileExplorerNameFilter": { "26fb73c6e3": "Buscar archivos", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index ff0909bf734..067b720b72b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2436,7 +2436,9 @@ "9acaf92093": "ペインの操作", "1bce81dba6": "シミュレータ", "1ff1c77616": "ブラウザ", - "586d2ac445": "terminal" + "586d2ac445": "terminal", + "addSplitPane": "分割ペインを追加", + "closePaneColumn": "分割ペインを閉じる" }, "AiVaultSessionDropLayer": { "dropOntoTerminalPane": "Drop onto a terminal pane to resume this session.", @@ -2444,6 +2446,9 @@ "localWorkspacesOnly": "Resume from history is only available in local workspaces.", "openLocalWorkspace": "Open a local workspace before resuming a session.", "sessionQueued": "Session queued" + }, + "TabGroupDropOverlay": { + "paneColumnLabel": "新しい分割" } }, "bar": { @@ -2514,7 +2519,9 @@ "cb3eadefd2": "青", "20baa43c05": "なし", "60f958ec75": "ピンタブ", - "417722e9c2": "タブの固定を解除する" + "417722e9c2": "タブの固定を解除する", + "splitTerminalRight": "ターミナルを右に分割", + "splitTerminalDown": "ターミナルを下に分割" }, "TabBar": { "b1a132357f": "新規タブ", @@ -2606,6 +2613,13 @@ } } } + }, + "TabWorkspaceLayoutMenuSection": { + "right": "右", + "left": "左", + "down": "下", + "up": "上", + "moveToPaneColumn": "タブを分割へ移動" } } }, @@ -9127,15 +9141,15 @@ "monthsAgo": "{{value0}}mo ago", "yearsAgo": "{{value0}}y ago", "sessionId": "セッション ID", - "originalAsk": "Original ask", - "latestTurns": "Latest turns", - "noPreviewAvailable": "No conversation preview available", - "messageCount": "{{value0}} msgs", - "userRole": "You", - "agentRole": "Agent", - "toolRole": "Tool", - "systemRole": "System", - "sessionRole": "Session" + "originalAsk": "最初の依頼", + "latestTurns": "最新のやり取り", + "noPreviewAvailable": "会話プレビューはありません", + "messageCount": "{{value0}} 件のメッセージ", + "userRole": "あなた", + "agentRole": "エージェント", + "toolRole": "ツール", + "systemRole": "システム", + "sessionRole": "セッション" }, "AiVaultSessionRow": { "resumeAgentSession": "Resume {{value0}} session", @@ -9153,13 +9167,13 @@ "showDetails": "詳細を表示", "moreSessionActions": "セッションのその他の操作", "moreActions": "その他の操作", - "noPreviewAvailable": "No conversation preview available", - "dragToResume": "Drag to resume in a new tab", - "userRole": "You", - "agentRole": "Agent", - "toolRole": "Tool", - "systemRole": "System", - "sessionRole": "Session" + "noPreviewAvailable": "会話プレビューはありません", + "dragToResume": "新しいタブで再開するにはドラッグ", + "userRole": "あなた", + "agentRole": "エージェント", + "toolRole": "ツール", + "systemRole": "システム", + "sessionRole": "セッション" }, "FileExplorerNameFilter": { "26fb73c6e3": "ファイルを検索", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 66435772ac5..04172df8fa4 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2436,7 +2436,9 @@ "9acaf92093": "창 작업", "1bce81dba6": "시뮬레이터", "1ff1c77616": "브라우저", - "586d2ac445": "terminal" + "586d2ac445": "terminal", + "addSplitPane": "분할 창 추가", + "closePaneColumn": "분할 창 닫기" }, "AiVaultSessionDropLayer": { "dropOntoTerminalPane": "이 세션을 재개하려면 terminal 창에 놓으세요.", @@ -2444,6 +2446,9 @@ "localWorkspacesOnly": "기록에서 재개하기는 로컬 워크스페이스에서만 사용할 수 있습니다.", "openLocalWorkspace": "세션을 재개하기 전에 로컬 워크스페이스를 여세요.", "sessionQueued": "세션이 대기열에 추가됨" + }, + "TabGroupDropOverlay": { + "paneColumnLabel": "새 분할" } }, "bar": { @@ -2514,7 +2519,9 @@ "cb3eadefd2": "파란색", "20baa43c05": "없음", "60f958ec75": "탭 고정", - "417722e9c2": "탭 고정 해제" + "417722e9c2": "탭 고정 해제", + "splitTerminalRight": "터미널을 오른쪽으로 분할", + "splitTerminalDown": "터미널을 아래로 분할" }, "TabBar": { "b1a132357f": "새 탭", @@ -2606,6 +2613,13 @@ } } } + }, + "TabWorkspaceLayoutMenuSection": { + "right": "오른쪽", + "left": "왼쪽", + "down": "아래", + "up": "위", + "moveToPaneColumn": "탭을 분할로 이동" } } }, @@ -9127,15 +9141,15 @@ "monthsAgo": "{{value0}}개월 전", "yearsAgo": "{{value0}}년 전", "sessionId": "세션 ID", - "originalAsk": "Original ask", - "latestTurns": "Latest turns", - "noPreviewAvailable": "No conversation preview available", - "messageCount": "{{value0}} msgs", - "userRole": "You", - "agentRole": "Agent", - "toolRole": "Tool", - "systemRole": "System", - "sessionRole": "Session" + "originalAsk": "원래 요청", + "latestTurns": "최근 대화", + "noPreviewAvailable": "대화 미리보기를 사용할 수 없습니다", + "messageCount": "메시지 {{value0}}개", + "userRole": "나", + "agentRole": "에이전트", + "toolRole": "도구", + "systemRole": "시스템", + "sessionRole": "세션" }, "AiVaultSessionRow": { "resumeAgentSession": "{{value0}} 세션 재개", @@ -9153,13 +9167,13 @@ "showDetails": "세부정보 표시", "moreSessionActions": "세션 추가 작업", "moreActions": "추가 작업", - "noPreviewAvailable": "No conversation preview available", - "dragToResume": "Drag to resume in a new tab", - "userRole": "You", - "agentRole": "Agent", - "toolRole": "Tool", - "systemRole": "System", - "sessionRole": "Session" + "noPreviewAvailable": "대화 미리보기를 사용할 수 없습니다", + "dragToResume": "새 탭에서 재개하려면 드래그", + "userRole": "나", + "agentRole": "에이전트", + "toolRole": "도구", + "systemRole": "시스템", + "sessionRole": "세션" }, "FileExplorerNameFilter": { "26fb73c6e3": "파일 찾기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e07d3e89490..688829e7a64 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2436,7 +2436,9 @@ "9acaf92093": "窗格操作", "1bce81dba6": "模拟器", "1ff1c77616": "浏览器", - "586d2ac445": "terminal" + "586d2ac445": "terminal", + "addSplitPane": "添加拆分窗格", + "closePaneColumn": "关闭拆分窗格" }, "AiVaultSessionDropLayer": { "dropOntoTerminalPane": "拖放到 terminal 面板以恢复此会话。", @@ -2444,6 +2446,9 @@ "localWorkspacesOnly": "从历史恢复仅支持本地工作区。", "openLocalWorkspace": "恢复会话前请先打开一个本地工作区。", "sessionQueued": "会话已排队" + }, + "TabGroupDropOverlay": { + "paneColumnLabel": "新建拆分" } }, "bar": { @@ -2514,7 +2519,9 @@ "cb3eadefd2": "Blue", "20baa43c05": "没有任何", "60f958ec75": "引脚标签", - "417722e9c2": "取消固定标签" + "417722e9c2": "取消固定标签", + "splitTerminalRight": "向右拆分终端", + "splitTerminalDown": "向下拆分终端" }, "TabBar": { "b1a132357f": "新标签页", @@ -2606,6 +2613,13 @@ } } } + }, + "TabWorkspaceLayoutMenuSection": { + "right": "右", + "left": "左", + "down": "下", + "up": "上", + "moveToPaneColumn": "将标签页移至拆分" } } }, @@ -9127,15 +9141,15 @@ "monthsAgo": "{{value0}} 个月前", "yearsAgo": "{{value0}} 年前", "sessionId": "会话 ID", - "originalAsk": "Original ask", - "latestTurns": "Latest turns", - "noPreviewAvailable": "No conversation preview available", - "messageCount": "{{value0}} msgs", - "userRole": "You", - "agentRole": "Agent", - "toolRole": "Tool", - "systemRole": "System", - "sessionRole": "Session" + "originalAsk": "原始请求", + "latestTurns": "最近轮次", + "noPreviewAvailable": "没有可用的对话预览", + "messageCount": "{{value0}} 条消息", + "userRole": "你", + "agentRole": "智能体", + "toolRole": "工具", + "systemRole": "系统", + "sessionRole": "会话" }, "AiVaultSessionRow": { "resumeAgentSession": "恢复 {{value0}} 会话", @@ -9153,13 +9167,13 @@ "showDetails": "显示详情", "moreSessionActions": "更多会话操作", "moreActions": "更多操作", - "noPreviewAvailable": "No conversation preview available", - "dragToResume": "Drag to resume in a new tab", - "userRole": "You", - "agentRole": "Agent", - "toolRole": "Tool", - "systemRole": "System", - "sessionRole": "Session" + "noPreviewAvailable": "没有可用的对话预览", + "dragToResume": "拖动以在新标签页中恢复", + "userRole": "你", + "agentRole": "智能体", + "toolRole": "工具", + "systemRole": "系统", + "sessionRole": "会话" }, "FileExplorerNameFilter": { "26fb73c6e3": "查找文件", diff --git a/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts b/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts index adfc43809fb..e09553f4993 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts @@ -1,6 +1,11 @@ import type { DropZone, ManagedPaneInternal } from './pane-manager-types' import type { DragReorderCallbacks, DragReorderState } from './pane-drag-reorder' -import { handlePaneDrop, hideDropOverlay, showDropOverlay } from './pane-drag-reorder' +import { + handlePaneDrop, + hideDropOverlay, + isPaneDropNoOp, + showDropOverlay +} from './pane-drag-reorder' const DRAG_THRESHOLD = 5 @@ -161,6 +166,15 @@ function updateDropTarget( const rect = targetPane.container.getBoundingClientRect() const zone = resolveDropZone(clientX, clientY, rect) + const sourcePaneId = state.dragSourcePaneId + if ( + sourcePaneId !== null && + isPaneDropNoOp(sourcePaneId, targetPane.id, zone, callbacks.getPanes()) + ) { + overlay.style.display = 'none' + state.currentDropTarget = null + return + } state.currentDropTarget = { paneId: targetPane.id, zone } positionDropOverlay(overlay, rect, zone) } diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts index 9a19ef94729..83a0eb1dacc 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ManagedPaneInternal } from './pane-manager-types' +import type * as PaneTreeOpsModule from './pane-tree-ops' import { attachPaneDrag } from './pane-drag-pointer' import { createDragReorderState } from './pane-drag-reorder' import type { TerminalLeafId } from '../../../../shared/stable-pane-id' @@ -7,10 +8,14 @@ import type { TerminalLeafId } from '../../../../shared/stable-pane-id' const detachPaneFromTree = vi.hoisted(() => vi.fn()) const insertPaneNextTo = vi.hoisted(() => vi.fn()) -vi.mock('./pane-tree-ops', () => ({ - detachPaneFromTree, - insertPaneNextTo -})) +vi.mock('./pane-tree-ops', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + detachPaneFromTree, + insertPaneNextTo + } +}) type FakeListener = (event: PointerEvent) => void diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts index ad730523ca1..6b08dc4b48e 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts @@ -1,6 +1,6 @@ import type { DropZone, ManagedPane, ManagedPaneInternal } from './pane-manager-types' import type { PaneStyleOptions } from './pane-manager-types' -import { detachPaneFromTree, insertPaneNextTo } from './pane-tree-ops' +import { detachPaneFromTree, findPaneChildren, insertPaneNextTo } from './pane-tree-ops' // --------------------------------------------------------------------------- // Drag-to-reorder panes @@ -46,6 +46,47 @@ export function cancelActivePaneDrag(state: DragReorderState): void { state.currentDropTarget = null } +/** True when dropping source onto target in zone would leave pane order unchanged. */ +export function isPaneDropNoOp( + sourcePaneId: number, + targetPaneId: number, + zone: DropZone, + panes: Map +): boolean { + if (sourcePaneId === targetPaneId) { + return true + } + const source = panes.get(sourcePaneId) + const target = panes.get(targetPaneId) + if (!source || !target) { + return true + } + + const parent = target.container.parentElement + if (!parent?.classList.contains('pane-split')) { + return false + } + if (source.container.parentElement !== parent) { + return false + } + + const children = findPaneChildren(parent) + const targetIndex = children.indexOf(target.container) + const sourceIndex = children.indexOf(source.container) + if (targetIndex === -1 || sourceIndex === -1) { + return false + } + + const isVerticalSplit = parent.classList.contains('is-vertical') + const zoneIsHorizontal = zone === 'top' || zone === 'bottom' + if (zoneIsHorizontal === isVerticalSplit) { + return false + } + return zone === 'right' || zone === 'bottom' + ? sourceIndex === targetIndex + 1 + : sourceIndex === targetIndex - 1 +} + /** Move a pane from its current position to a new position relative to a target pane. */ export function handlePaneDrop( sourcePaneId: number, @@ -58,6 +99,9 @@ export function handlePaneDrop( return } const panes = callbacks.getPanes() + if (isPaneDropNoOp(sourcePaneId, targetPaneId, zone, panes)) { + return + } const source = panes.get(sourcePaneId) const target = panes.get(targetPaneId) if (!source || !target) { diff --git a/src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts b/src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts new file mode 100644 index 00000000000..fc63ebd9ad6 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts @@ -0,0 +1,81 @@ +/** + * @vitest-environment happy-dom + */ +import { describe, expect, it } from 'vitest' +import type { ManagedPaneInternal } from './pane-manager-types' +import { isPaneDropNoOp } from './pane-drag-reorder' +import type { TerminalLeafId } from '../../../../shared/stable-pane-id' + +function makeLeafId(id: number): TerminalLeafId { + return `${id}${id}${id}${id}${id}${id}${id}${id}-${id}${id}${id}${id}-4${id}${id}${id}-8${id}${id}${id}-${id}${id}${id}${id}${id}${id}${id}${id}${id}${id}${id}${id}` as TerminalLeafId +} + +function createPane(id: number, container: HTMLElement): ManagedPaneInternal { + const leafId = makeLeafId(id) + container.classList.add('pane') + container.dataset.paneId = String(id) + container.dataset.leafId = leafId + return { + id, + leafId, + stablePaneId: leafId, + terminal: {} as never, + container, + xtermContainer: {} as never, + linkTooltip: {} as never, + terminalGpuAcceleration: 'auto', + gpuRenderingEnabled: true, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, + webglAddon: null, + ligaturesAddon: null, + fitResizeObserver: null, + pendingObservedFitRafId: null, + fitAddon: {} as never, + searchAddon: {} as never, + serializeAddon: {} as never, + unicode11Addon: {} as never, + webLinksAddon: {} as never, + compositionHandler: null, + pendingSplitScrollState: null, + debugLabel: null + } +} + +function createVerticalSplit(paneIds: readonly number[]): Map { + const split = document.createElement('div') + split.className = 'pane-split is-vertical' + document.body.appendChild(split) + + const panes = new Map() + for (const id of paneIds) { + const container = document.createElement('div') + split.appendChild(container) + panes.set(id, createPane(id, container)) + } + return panes +} + +describe('isPaneDropNoOp', () => { + it('treats dropping an already-right sibling onto the left pane as a no-op', () => { + const panes = createVerticalSplit([1, 2]) + expect(isPaneDropNoOp(2, 1, 'right', panes)).toBe(true) + }) + + it('treats dropping an already-left sibling onto the right pane as a no-op', () => { + const panes = createVerticalSplit([1, 2]) + expect(isPaneDropNoOp(1, 2, 'left', panes)).toBe(true) + }) + + it('allows reordering when a third pane sits between source and target', () => { + const panes = createVerticalSplit([1, 2, 3]) + expect(isPaneDropNoOp(3, 1, 'right', panes)).toBe(false) + }) + + it('allows swapping adjacent vertical panes via the opposite edge', () => { + const panes = createVerticalSplit([1, 2]) + expect(isPaneDropNoOp(2, 1, 'left', panes)).toBe(false) + expect(isPaneDropNoOp(1, 2, 'right', panes)).toBe(false) + }) +}) diff --git a/src/renderer/src/store/slices/pane-column-split-drop-no-op.test.ts b/src/renderer/src/store/slices/pane-column-split-drop-no-op.test.ts new file mode 100644 index 00000000000..f5c4d68b309 --- /dev/null +++ b/src/renderer/src/store/slices/pane-column-split-drop-no-op.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import type { TabGroupLayoutNode } from '../../../../shared/types' +import { + findLayoutSiblingOnSplitSide, + isPaneColumnSplitDropNoOp +} from './pane-column-split-drop-no-op' + +function horizontalSplit(firstGroupId: string, secondGroupId: string): TabGroupLayoutNode { + return { + type: 'split', + direction: 'horizontal', + ratio: 0.5, + first: { type: 'leaf', groupId: firstGroupId }, + second: { type: 'leaf', groupId: secondGroupId } + } +} + +describe('findLayoutSiblingOnSplitSide', () => { + const layout = horizontalSplit('group-left', 'group-right') + + it('finds the right sibling when splitting the left column to the right', () => { + expect(findLayoutSiblingOnSplitSide(layout, 'group-left', 'right')).toBe('group-right') + }) + + it('finds the left sibling when splitting the right column to the left', () => { + expect(findLayoutSiblingOnSplitSide(layout, 'group-right', 'left')).toBe('group-left') + }) + + it('returns null for split directions that would not target the adjacent sibling', () => { + expect(findLayoutSiblingOnSplitSide(layout, 'group-left', 'left')).toBeNull() + expect(findLayoutSiblingOnSplitSide(layout, 'group-right', 'right')).toBeNull() + }) +}) + +describe('isPaneColumnSplitDropNoOp', () => { + const layout = horizontalSplit('group-left', 'group-right') + + it('treats splitting the only tab in a group onto itself as a no-op', () => { + expect( + isPaneColumnSplitDropNoOp({ + sourceGroupId: 'group-left', + targetGroupId: 'group-left', + splitDirection: 'right', + sourceTabCount: 1, + layout + }) + ).toBe(true) + }) + + it('treats dragging the only tab onto the adjacent sibling edge as a no-op', () => { + expect( + isPaneColumnSplitDropNoOp({ + sourceGroupId: 'group-right', + targetGroupId: 'group-left', + splitDirection: 'right', + sourceTabCount: 1, + layout + }) + ).toBe(true) + expect( + isPaneColumnSplitDropNoOp({ + sourceGroupId: 'group-left', + targetGroupId: 'group-right', + splitDirection: 'left', + sourceTabCount: 1, + layout + }) + ).toBe(true) + }) + + it('allows splits when the source group would still have other tabs', () => { + expect( + isPaneColumnSplitDropNoOp({ + sourceGroupId: 'group-right', + targetGroupId: 'group-left', + splitDirection: 'right', + sourceTabCount: 2, + layout + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/store/slices/pane-column-split-drop-no-op.ts b/src/renderer/src/store/slices/pane-column-split-drop-no-op.ts new file mode 100644 index 00000000000..9f87e4a20a5 --- /dev/null +++ b/src/renderer/src/store/slices/pane-column-split-drop-no-op.ts @@ -0,0 +1,71 @@ +import type { TabGroupLayoutNode } from '../../../../shared/types' +import type { TabSplitDirection } from './tabs' + +function getDirectLayoutSiblingOnSplitSide( + split: Extract, + targetGroupId: string, + splitDirection: TabSplitDirection +): string | null { + const { first, second, direction } = split + + if (first.type === 'leaf' && first.groupId === targetGroupId) { + if (direction === 'horizontal' && splitDirection === 'right' && second.type === 'leaf') { + return second.groupId + } + if (direction === 'vertical' && splitDirection === 'down' && second.type === 'leaf') { + return second.groupId + } + } + + if (second.type === 'leaf' && second.groupId === targetGroupId) { + if (direction === 'horizontal' && splitDirection === 'left' && first.type === 'leaf') { + return first.groupId + } + if (direction === 'vertical' && splitDirection === 'up' && first.type === 'leaf') { + return first.groupId + } + } + + return null +} + +export function findLayoutSiblingOnSplitSide( + root: TabGroupLayoutNode, + targetGroupId: string, + splitDirection: TabSplitDirection +): string | null { + if (root.type === 'leaf') { + return null + } + + const directSibling = getDirectLayoutSiblingOnSplitSide(root, targetGroupId, splitDirection) + if (directSibling) { + return directSibling + } + + return ( + findLayoutSiblingOnSplitSide(root.first, targetGroupId, splitDirection) ?? + findLayoutSiblingOnSplitSide(root.second, targetGroupId, splitDirection) + ) +} + +/** True when a pane-column split drop would collapse back to the current layout. */ +export function isPaneColumnSplitDropNoOp(args: { + sourceGroupId: string + targetGroupId: string + splitDirection: TabSplitDirection + sourceTabCount: number + layout: TabGroupLayoutNode | undefined +}): boolean { + if (args.sourceGroupId === args.targetGroupId && args.sourceTabCount <= 1) { + return true + } + if (args.sourceTabCount !== 1 || !args.layout) { + return false + } + + return ( + findLayoutSiblingOnSplitSide(args.layout, args.targetGroupId, args.splitDirection) === + args.sourceGroupId + ) +} diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index 792c722f558..358d9b9598c 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -975,9 +975,7 @@ describe('TabsSlice', () => { const state = store.getState() const moved = state.unifiedTabsByWorktree[WT].find((item) => item.id === tab.id) expect(moved?.groupId).toBe(targetGroupId) - expect( - state.groupsByWorktree[WT].find((group) => group.id === sourceGroupId)?.tabOrder - ).toEqual([]) + expect(state.groupsByWorktree[WT].find((group) => group.id === sourceGroupId)).toBeUndefined() expect( state.groupsByWorktree[WT].find((group) => group.id === targetGroupId)?.tabOrder ).toEqual([tab.id]) @@ -1200,6 +1198,40 @@ describe('TabsSlice', () => { expect(state.groupsByWorktree[WT][0].tabOrder).toEqual([onlyTab.id]) expect(state.layoutByWorktree[WT]).toEqual({ type: 'leaf', groupId: sourceGroupId }) }) + + it('treats splitting the only tab onto the adjacent sibling edge as a no-op', () => { + store.getState().createUnifiedTab(WT, 'editor', { + id: 'file-a.ts', + label: 'file-a.ts' + }) + const right = store.getState().createUnifiedTab(WT, 'terminal', { + id: 'terminal-1', + label: 'Terminal 1' + }) + const leftGroupId = store.getState().groupsByWorktree[WT][0].id + + expect( + store.getState().dropUnifiedTab(right.id, { + groupId: leftGroupId, + splitDirection: 'right' + }) + ).toBe(true) + + const rightGroupId = store + .getState() + .unifiedTabsByWorktree[WT].find((tab) => tab.id === right.id)?.groupId + expect(rightGroupId).toBeTruthy() + + const moved = store.getState().dropUnifiedTab(right.id, { + groupId: leftGroupId, + splitDirection: 'right' + }) + + expect(moved).toBe(false) + expect( + store.getState().unifiedTabsByWorktree[WT].find((tab) => tab.id === right.id)?.groupId + ).toBe(rightGroupId) + }) }) // ─── setTabLabel / setTabCustomLabel / setUnifiedTabColor ───────── diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 0500aa1f9a5..04e83253973 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -26,6 +26,7 @@ import { sanitizeRecentTabIds, updateGroup } from './tab-group-state' +import { isPaneColumnSplitDropNoOp } from './pane-column-split-drop-no-op' import { buildHydratedTabState, pruneTabGroupLayoutForGroups } from './tabs-hydration' import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds } from './terminal-orphan-helpers' import { createBrowserUuid } from '@/lib/browser-uuid' @@ -1378,18 +1379,49 @@ export const createTabsSlice: StateCreator = (set, } return group }) + let nextLayoutByWorktree = state.layoutByWorktree + let nextActiveGroupIdByWorktreeResolved = nextActiveGroupIdByWorktree + let filteredGroups = nextGroups + if (sourceOrder.length === 0) { + filteredGroups = nextGroups.filter((group) => group.id !== sourceGroup.id) + const collapsedState = collapseGroupLayout( + nextLayoutByWorktree, + nextActiveGroupIdByWorktreeResolved, + worktreeId, + sourceGroup.id, + targetGroupId + ) + nextLayoutByWorktree = collapsedState.layoutByWorktree + nextActiveGroupIdByWorktreeResolved = collapsedState.activeGroupIdByWorktree + } + const nextGroupsByWorktree = { + ...state.groupsByWorktree, + [worktreeId]: filteredGroups + } + const nextUnifiedTabsByWorktree = { + ...state.unifiedTabsByWorktree, + [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => + candidate.id === tabId ? { ...candidate, groupId: targetGroupId } : candidate + ) + } return { - unifiedTabsByWorktree: { - ...state.unifiedTabsByWorktree, - [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => - candidate.id === tabId ? { ...candidate, groupId: targetGroupId } : candidate - ) - }, - groupsByWorktree: { - ...state.groupsByWorktree, - [worktreeId]: nextGroups - }, - activeGroupIdByWorktree: nextActiveGroupIdByWorktree + unifiedTabsByWorktree: nextUnifiedTabsByWorktree, + groupsByWorktree: nextGroupsByWorktree, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktreeResolved, + ...(state.activeWorktreeId === worktreeId + ? buildActiveSurfacePatch( + { + ...state, + unifiedTabsByWorktree: nextUnifiedTabsByWorktree, + groupsByWorktree: nextGroupsByWorktree, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktreeResolved + }, + worktreeId, + nextActiveGroupIdByWorktreeResolved[worktreeId] ?? null + ) + : {}) } }) if (moved && opts?.recordInteraction !== false) { @@ -1418,11 +1450,20 @@ export const createTabsSlice: StateCreator = (set, if (!isSplitDrop && tab.groupId === target.groupId) { return {} } - if (isSplitDrop && tab.groupId === target.groupId && sourceGroup.tabOrder.length <= 1) { - // Why: dragging the final tab in a group onto that same group's edge - // would create a transient sibling only to collapse the source - // immediately, leaving the layout unchanged while still churning focus - // and group IDs. Treat that as a no-op instead of faking a split. + const layout = state.layoutByWorktree[worktreeId] + if ( + isSplitDrop && + isPaneColumnSplitDropNoOp({ + sourceGroupId: sourceGroup.id, + targetGroupId: target.groupId, + splitDirection: target.splitDirection!, + sourceTabCount: sourceGroup.tabOrder.length, + layout + }) + ) { + // Why: dragging the final tab in a group onto that same group's edge, + // or onto the adjacent sibling's matching edge, creates a transient + // column only to collapse the emptied source immediately. return {} } diff --git a/tests/e2e/terminal-panes.spec.ts b/tests/e2e/terminal-panes.spec.ts index ee0a2d3d12d..6efc29f837f 100644 --- a/tests/e2e/terminal-panes.spec.ts +++ b/tests/e2e/terminal-panes.spec.ts @@ -844,16 +844,12 @@ test.describe('Terminal Panes', () => { await expect(orcaPage.locator('.pane-title-text', { hasText: title })).toHaveCount(1) }) - test('Set Title remove button hover stays transparent', async ({ orcaPage }) => { - const title = `Remove hover title ${Date.now()}` + test('Always-on pane header split button hover stays transparent', async ({ orcaPage }) => { + const splitButton = orcaPage.getByRole('button', { name: 'Split Terminal Right' }) + await expect(splitButton).toBeVisible() + await splitButton.hover() - await setPaneTitleFromTerminalMenu(orcaPage, title) - const removeButton = orcaPage.getByRole('button', { name: `Remove pane title: ${title}` }) - await orcaPage.locator('.pane-title-bar', { hasText: title }).hover() - await removeButton.hover() - await expect(orcaPage.getByText('Remove title', { exact: true })).toBeVisible() - - const hoverStyle = await removeButton.evaluate((element) => { + const hoverStyle = await splitButton.evaluate((element) => { const style = getComputedStyle(element) return { backgroundColor: style.backgroundColor, @@ -907,8 +903,7 @@ test.describe('Terminal Panes', () => { await expectSavedLayoutNotToContainTitle(orcaPage, tabId, paneTitle) await setPaneTitleFromTerminalMenu(orcaPage, removeButtonTitle) - await orcaPage.locator('.pane-title-bar', { hasText: removeButtonTitle }).hover() - await orcaPage.getByRole('button', { name: `Remove pane title: ${removeButtonTitle}` }).click() + await setPaneTitleFromTerminalMenu(orcaPage, '') await expect(orcaPage.locator('.pane-title-text', { hasText: removeButtonTitle })).toBeHidden() await expectSavedLayoutNotToContainTitle(orcaPage, tabId, removeButtonTitle) From 52f45b35594756780df3e37436165b4808a83cc7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 21 Jun 2026 23:33:57 -0700 Subject: [PATCH 014/282] Handle literal Codex hook trust keys (#5872) --- src/main/codex/config-toml-trust.test.ts | 80 ++++++++++++ src/main/codex/config-toml-trust.ts | 149 ++++++++++++++++------- 2 files changed, 188 insertions(+), 41 deletions(-) diff --git a/src/main/codex/config-toml-trust.test.ts b/src/main/codex/config-toml-trust.test.ts index 6b79f0ff680..749a40f56d3 100644 --- a/src/main/codex/config-toml-trust.test.ts +++ b/src/main/codex/config-toml-trust.test.ts @@ -386,6 +386,34 @@ describe('upsertHookTrustEntries', () => { expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`) }) + it('collapses a literal-string hook table before writing the canonical basic-string table', () => { + const sourcePath = 'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json' + const key = `${sourcePath}:session_start:0:0` + const original = [ + `[hooks.state.'${key}']`, + 'enabled = false', + 'trusted_hash = "sha256:LITERAL"', + '' + ].join('\r\n') + writeFileSync(configPath, original, 'utf-8') + + const entry: CodexTrustEntry = { + sourcePath, + eventLabel: 'session_start', + groupIndex: 0, + handlerIndex: 0, + command: 'echo session' + } + upsertHookTrustEntries(configPath, [entry]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).not.toContain(`[hooks.state.'${key}']`) + expect(written.match(/\[hooks\.state\./g)).toHaveLength(1) + expect(written).toContain(`[hooks.state."${escapeTomlString(key)}"]`) + expect(written).toContain('enabled = false') + expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`) + }) + it('writes a .bak file before overwriting an existing config', () => { writeFileSync(configPath, 'model = "old"\n', 'utf-8') upsertHookTrustEntries(configPath, [ @@ -947,6 +975,44 @@ describe('removeHookTrustEntries', () => { expect(written).toContain('sha256:OTHER') }) + it('removes a literal-string hook table for the requested key', () => { + const key = 'C:\\x\\hooks.json:session_start:0:0' + const original = [ + `[hooks.state.'${key}']`, + 'enabled = true', + 'trusted_hash = "sha256:LITERAL"', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + removeHookTrustEntries(configPath, [key]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).not.toContain(`[hooks.state.'${key}']`) + expect(written).not.toContain('sha256:LITERAL') + }) + + it('removes mixed quoting duplicates for the requested key', () => { + const key = 'C:\\x\\hooks.json:session_start:0:0' + const original = [ + `[hooks.state.'${key}']`, + 'enabled = true', + 'trusted_hash = "sha256:LITERAL"', + '', + `[hooks.state."${escapeTomlString(key)}"]`, + 'enabled = true', + 'trusted_hash = "sha256:BASIC"', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + removeHookTrustEntries(configPath, [key]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).not.toContain(`[hooks.state.'${key}']`) + expect(written).not.toContain(`[hooks.state."${escapeTomlString(key)}"]`) + }) + it('does not remove the target hook header text inside a multi-line string', () => { const key = '/x/hooks.json:pre_tool_use:0:0' const original = [ @@ -1131,6 +1197,20 @@ describe('readHookTrustEntries', () => { expect(result.get('C:\\foo\\hooks.json:pre_tool_use:0:0')?.trustedHash).toBe('sha256:WIN') }) + it('reads a literal-string hook table key', () => { + const key = 'C:\\foo\\hooks.json:session_start:0:0' + const original = [ + `[hooks.state.'${key}']`, + 'enabled = false', + 'trusted_hash = "sha256:LITERAL"', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + const result = readHookTrustEntries(configPath) + expect(result.get(key)).toEqual({ trustedHash: 'sha256:LITERAL', enabled: false }) + }) + it('reads entries from a CRLF-terminated config', () => { const key = '/x/hooks.json:pre_tool_use:0:0' const original = [ diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index bb301548b13..0b52b9edc2c 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -336,15 +336,6 @@ function upsertTrustBlock(content: string, key: string, hash: string): string { return deduped + content.slice(cursor) } -// Why: Codex emits the canonical form with the key double-quoted; we never -// share this slot with another tool, so we don't bother accepting bare -// dotted-key variants. The caller applies this only to complete physical lines -// outside TOML multi-line strings. -function buildHeaderLinePattern(key: string): RegExp { - const escapedKey = escapeRegex(escapeTomlString(key)) - return new RegExp(`^[ \\t]*\\[hooks\\.state\\."${escapedKey}"\\][ \\t]*(?:#[^\\r\\n]*)?$`) -} - type TrustBlockRange = { start: number headerLineEnd: number @@ -352,7 +343,6 @@ type TrustBlockRange = { } function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] { - const headerPattern = buildHeaderLinePattern(key) const ranges: TrustBlockRange[] = [] let cursor = 0 let multilineState: TomlMultilineState = { basic: false, literal: false } @@ -362,7 +352,10 @@ function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] { const rawLine = content.slice(cursor, lineEnd) const line = rawLine.replace(/\r$/, '') const nextCursor = newlineIdx === -1 ? content.length : newlineIdx + 1 - if (!isInsideTomlMultilineString(multilineState) && headerPattern.test(line)) { + const headerKey = isInsideTomlMultilineString(multilineState) + ? null + : parseHookStateHeaderKey(line) + if (headerKey === key) { const headerLineEnd = rawLine.endsWith('\r') ? lineEnd - 1 : lineEnd const after = content.slice(headerLineEnd) const nextHeaderRel = findNextTableHeader(after) @@ -383,6 +376,80 @@ function buildProjectHeaderPattern(projectPath: string): RegExp { `(^|\\r?\\n)[ \\t]*\\[projects\\."${escapedPath}"\\][ \\t]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)` ) } + +type ParsedTomlString = { + value: string + endIndex: number +} + +// Why: Codex can write hook-state dotted keys as TOML basic strings while +// users/Codex TUI may leave equivalent literal-string keys behind. +function parseHookStateHeaderKey(line: string): string | null { + const trimmed = line.trimStart() + const prefixMatch = /^\[[ \t]*hooks[ \t]*\.[ \t]*state[ \t]*\.[ \t]*/.exec(trimmed) + if (!prefixMatch) { + return null + } + const parsedKey = parseTomlSingleLineString(trimmed, prefixMatch[0].length) + if (!parsedKey) { + return null + } + let index = skipTomlInlineWhitespace(trimmed, parsedKey.endIndex) + if (trimmed[index] !== ']') { + return null + } + index = skipTomlInlineWhitespace(trimmed, index + 1) + return index === trimmed.length || trimmed[index] === '#' ? parsedKey.value : null +} + +function parseTomlSingleLineString(line: string, startIndex: number): ParsedTomlString | null { + if (line[startIndex] === '"') { + return parseTomlBasicSingleLineString(line, startIndex + 1) + } + if (line[startIndex] === "'") { + return parseTomlLiteralSingleLineString(line, startIndex + 1) + } + return null +} + +function parseTomlBasicSingleLineString(line: string, startIndex: number): ParsedTomlString | null { + let value = '' + let index = startIndex + while (index < line.length) { + const char = line[index] + if (char === '"') { + return { value, endIndex: index + 1 } + } + if (char === '\\' && index + 1 < line.length) { + const next = line[index + 1] + value += unescapeTomlBasicStringEscape(next) + index += 2 + continue + } + value += char + index++ + } + return null +} + +function parseTomlLiteralSingleLineString( + line: string, + startIndex: number +): ParsedTomlString | null { + const endIndex = line.indexOf("'", startIndex) + if (endIndex === -1) { + return null + } + return { value: line.slice(startIndex, endIndex), endIndex: endIndex + 1 } +} + +function skipTomlInlineWhitespace(line: string, startIndex: number): number { + let index = startIndex + while (line[index] === ' ' || line[index] === '\t') { + index++ + } + return index +} // Why: quoted keys can contain `]` (e.g. `[hooks.state."a]b"]`) and `[` lines // inside multi-line strings aren't headers, so we need a stateful scanner — // a flat regex misclassifies both cases. @@ -621,9 +688,6 @@ export function readHookTrustEntries(configPath: string): Map Date: Sun, 21 Jun 2026 23:48:45 -0700 Subject: [PATCH 015/282] Fix early PTY startup output races (#6015) --- src/main/daemon/pty-subprocess.test.ts | 42 ++++++++++ src/main/daemon/pty-subprocess.ts | 58 ++++++++++++- .../pty-data-sidecar-subscriptions.ts | 23 ++++++ .../pty-dispatcher-pi-routing.test.ts | 63 ++++++++++++++- .../terminal-pane/pty-dispatcher.ts | 43 +++++----- .../terminal-pane/pty-pre-handler-buffer.ts | 81 +++++++++++++++++++ .../components/terminal-pane/pty-transport.ts | 19 ++++- src/renderer/src/lib/agent-draft-readiness.ts | 2 +- .../src/lib/agent-paste-draft.test.ts | 2 +- .../src/lib/automation-session-observer.ts | 3 +- .../launch-agent-background-session.test.ts | 5 +- .../lib/launch-agent-background-session.ts | 2 +- 12 files changed, 307 insertions(+), 36 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index cadff9b5e98..e2b8c155caa 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -756,6 +756,18 @@ describe('createPtySubprocess', () => { expect(data).toEqual(['hello']) }) + it('replays data emitted before the Session registers onData', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + proc._simulateData('early setup output\r\n') + const data: string[] = [] + handle.onData((d) => data.push(d)) + + expect(data).toEqual(['early setup output\r\n']) + }) + it('routes onExit events', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) @@ -768,6 +780,36 @@ describe('createPtySubprocess', () => { expect(codes).toEqual([42]) }) + it('replays pre-listener data before a pre-listener exit', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + proc._simulateData('last output\r\n') + proc._simulateExit(7) + const data: string[] = [] + const codes: number[] = [] + handle.onData((d) => data.push(d)) + handle.onExit((code) => codes.push(code)) + + expect(data).toEqual(['last output\r\n']) + expect(codes).toEqual([7]) + }) + + it('preserves pre-listener data when onExit is registered before onData', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + proc._simulateData('last output\r\n') + proc._simulateExit(7) + const events: string[] = [] + handle.onExit((code) => events.push(`exit:${code}`)) + handle.onData((data) => events.push(`data:${data}`)) + + expect(events).toEqual(['exit:7', 'data:last output\r\n']) + }) + it('sends signal via process.kill', () => { const proc = mockPtyProcess(99) spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 55fa199f5a2..22fd03412b6 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -51,6 +51,7 @@ const FOREGROUND_AGENT_CACHE_TTL_MS = 1000 const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000 const STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS = 5_000 const PTY_SPAWN_HEALTH_TIMEOUT_MS = 2_000 +const PENDING_PRE_LISTENER_DATA_MAX_CHARS = 512 * 1024 export type PtySubprocessOptions = { sessionId: string @@ -633,9 +634,52 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl let onDataCb: ((data: string) => void) | null = null let onExitCb: ((code: number) => void) | null = null + let pendingPreListenerData: string[] = [] + let pendingPreListenerDataChars = 0 + let pendingPreListenerExitCode: number | null = null - proc.onData((data) => onDataCb?.(data)) - proc.onExit(({ exitCode }) => onExitCb?.(exitCode)) + const bufferPreListenerData = (data: string): void => { + // Why: Windows shell-arg startup commands can print before Session wires + // this subprocess into the daemon. Preserve that spawn-time race window. + pendingPreListenerData.push(data) + pendingPreListenerDataChars += data.length + while (pendingPreListenerDataChars > PENDING_PRE_LISTENER_DATA_MAX_CHARS) { + const removed = pendingPreListenerData.shift() + if (removed === undefined) { + pendingPreListenerDataChars = 0 + return + } + pendingPreListenerDataChars -= removed.length + } + } + + const flushPreListenerData = (): void => { + if (!onDataCb || pendingPreListenerData.length === 0) { + return + } + const pending = pendingPreListenerData + pendingPreListenerData = [] + pendingPreListenerDataChars = 0 + for (const data of pending) { + onDataCb(data) + } + } + + proc.onData((data) => { + if (onDataCb) { + onDataCb(data) + } else { + bufferPreListenerData(data) + } + }) + proc.onExit(({ exitCode }) => { + if (onExitCb) { + flushPreListenerData() + onExitCb(exitCode) + } else { + pendingPreListenerExitCode = exitCode + } + }) // Why: node-pty's native NAPI layer throws a C++ Napi::Error when // write/resize/kill is called on a PTY whose underlying fd is already @@ -851,9 +895,16 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl }, onData: (cb) => { onDataCb = cb + flushPreListenerData() }, onExit: (cb) => { onExitCb = cb + if (pendingPreListenerExitCode !== null) { + const code = pendingPreListenerExitCode + pendingPreListenerExitCode = null + flushPreListenerData() + cb(code) + } }, dispose: () => { if (disposed) { @@ -863,6 +914,9 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl dead = true onDataCb = null onExitCb = null + pendingPreListenerData = [] + pendingPreListenerDataChars = 0 + pendingPreListenerExitCode = null // Why: UnixTerminal.destroy() registers `_socket.once('close', () => this.kill('SIGHUP'))` // (unixTerminal.js:219-229). The socket close fires asynchronously; by then // the child may have exited and its pid been recycled to an unrelated diff --git a/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts b/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts new file mode 100644 index 00000000000..af06e1b4a26 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts @@ -0,0 +1,23 @@ +import { ensurePtyDispatcher, ptyDataSidecars } from './pty-dispatcher' + +/** Register a side-channel data watcher for a PTY without taking ownership + * of the primary handler. Returns an unsubscribe fn. */ +export function subscribeToPtyData(ptyId: string, watcher: (data: string) => void): () => void { + ensurePtyDispatcher() + let set = ptyDataSidecars.get(ptyId) + if (!set) { + set = new Set() + ptyDataSidecars.set(ptyId, set) + } + set.add(watcher) + return () => { + const current = ptyDataSidecars.get(ptyId) + if (!current) { + return + } + current.delete(watcher) + if (current.size === 0) { + ptyDataSidecars.delete(ptyId) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts index e1bf44f05b0..76242cdde75 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts @@ -24,10 +24,12 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { let dispatcherCallback: | ((payload: { id: string; data: string; rawLength?: number }) => void) | null = null + let exitDispatcherCallback: ((payload: { id: string; code: number }) => void) | null = null beforeEach(() => { vi.resetModules() dispatcherCallback = null + exitDispatcherCallback = null ;(globalThis as { window: typeof window }).window = { ...originalWindow, api: { @@ -53,7 +55,12 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { } ), onReplay: vi.fn(() => () => {}), - onExit: vi.fn(() => () => {}) + onExit: vi.fn((cb: (payload: { id: string; code: number }) => void) => { + if (!exitDispatcherCallback) { + exitDispatcherCallback = cb + } + return () => {} + }) } } } as unknown as typeof window @@ -254,4 +261,58 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { transport.disconnect() }) + + it('replays PTY data that arrives before connect() registers the pane handler', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const onData = vi.fn() + let resolveSpawn: (value: { id: string }) => void = () => {} + + ;(window.api.pty.spawn as ReturnType).mockReturnValueOnce( + new Promise((resolve) => { + resolveSpawn = resolve + }) + ) + + const transport = createIpcPtyTransport() + const connectPromise = transport.connect({ url: '', callbacks: { onData } }) + + dispatcherCallback?.({ id: 'pty-early', data: 'setup starts here\r\n', rawLength: 19 }) + expect(onData).not.toHaveBeenCalled() + expect(window.api.pty.ackData).toHaveBeenCalledWith('pty-early', 19) + + resolveSpawn({ id: 'pty-early' }) + await connectPromise + + expect(onData).toHaveBeenCalledWith('setup starts here\r\n', { rawLength: 19 }) + + transport.disconnect() + }) + + it('replays pre-handler PTY data before a pre-handler exit', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const events: string[] = [] + let resolveSpawn: (value: { id: string }) => void = () => {} + + ;(window.api.pty.spawn as ReturnType).mockReturnValueOnce( + new Promise((resolve) => { + resolveSpawn = resolve + }) + ) + + const transport = createIpcPtyTransport() + const connectPromise = transport.connect({ + url: '', + callbacks: { + onData: (data) => events.push(`data:${data}`), + onExit: (code) => events.push(`exit:${code}`) + } + }) + + dispatcherCallback?.({ id: 'pty-fast-exit', data: 'last line\r\n', rawLength: 11 }) + exitDispatcherCallback?.({ id: 'pty-fast-exit', code: 3 }) + resolveSpawn({ id: 'pty-fast-exit' }) + await connectPromise + + expect(events).toEqual(['data:last line\r\n', 'exit:3']) + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index eefd4fb46a5..989b1303081 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -13,6 +13,11 @@ import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session import type { TuiAgent } from '../../../../shared/types' import { ackPtyData, exposeE2eTerminalPtyAckGate } from './terminal-pty-ack-gate' import { clampUtf8Tail, type EagerBufferChunk } from './pty-eager-buffer-clamp' +import { + bufferPreHandlerPtyData, + bufferPreHandlerPtyExit, + clearPreHandlerPtyState +} from './pty-pre-handler-buffer' // ── Singleton PTY event dispatcher ─────────────────────────────────── // One global IPC listener per channel, routes events to transports by @@ -43,28 +48,6 @@ export const ptyDataHandlers = new Map void>>() -/** Register a side-channel data watcher for a PTY without taking ownership - * of the primary handler. Returns an unsubscribe fn. ensurePtyDispatcher() - * is called automatically so the underlying IPC stream is wired up. */ -export function subscribeToPtyData(ptyId: string, watcher: (data: string) => void): () => void { - ensurePtyDispatcher() - let set = ptyDataSidecars.get(ptyId) - if (!set) { - set = new Set() - ptyDataSidecars.set(ptyId, set) - } - set.add(watcher) - return () => { - const current = ptyDataSidecars.get(ptyId) - if (!current) { - return - } - current.delete(watcher) - if (current.size === 0) { - ptyDataSidecars.delete(ptyId) - } - } -} /** Per-PTY replay handlers for relay pty.attach replay data. Routed through * a dedicated pty:replay IPC channel so the renderer can engage the replay * guard and suppress xterm auto-replies during replay. */ @@ -107,6 +90,7 @@ export function unregisterPtyDataHandlers(ptyIds: string[]): PtyDataHandlerShutd ptyReplayHandlers.delete(id) ptyTeardownHandlers.get(id)?.() ptyTeardownHandlers.delete(id) + clearPreHandlerPtyState(id) } return snapshots } @@ -144,7 +128,12 @@ export function ensurePtyDispatcher(): void { meta ??= {} meta.rawLength = payload.rawLength } - ptyDataHandlers.get(payload.id)?.(payload.data, meta) + const handler = ptyDataHandlers.get(payload.id) + if (handler) { + handler(payload.data, meta) + } else { + bufferPreHandlerPtyData(payload.id, payload.data, meta) + } const sidecars = ptyDataSidecars.get(payload.id) if (sidecars && sidecars.size > 0) { // Why: snapshot the Set before iterating because watchers commonly @@ -170,7 +159,13 @@ export function ensurePtyDispatcher(): void { ptyReplayHandlers.get(payload.id)?.(payload.data) }) window.api.pty.onExit((payload) => { - ptyExitHandlers.get(payload.id)?.(payload.code) + const handler = ptyExitHandlers.get(payload.id) + if (handler) { + clearPreHandlerPtyState(payload.id) + handler(payload.code) + } else { + bufferPreHandlerPtyExit(payload.id, payload.code) + } const sidecars = ptyExitSidecars.get(payload.id) if (sidecars && sidecars.size > 0) { const snapshot = Array.from(sidecars) diff --git a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts new file mode 100644 index 00000000000..2756f5fc146 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts @@ -0,0 +1,81 @@ +import { clampUtf8Tail } from './pty-eager-buffer-clamp' +import type { PtyDataMeta } from './pty-dispatcher' + +type BufferedPreHandlerPtyData = { + data: string + bytes: number + meta?: PtyDataMeta +} + +const preHandlerPtyData = new Map() +const preHandlerPtyExit = new Map() + +// Why: Windows startup commands can emit output before pty:spawn resolves and +// the pane registers its handler. Hold that tiny race window instead of ACKing +// and dropping the first setup-script bytes. +const PRE_HANDLER_PTY_DATA_MAX_BYTES = 512 * 1024 +const PRE_HANDLER_PTY_DATA_MAX_PTYS = 64 + +export function bufferPreHandlerPtyData(ptyId: string, data: string, meta?: PtyDataMeta): void { + const chunk = clampUtf8Tail(data, PRE_HANDLER_PTY_DATA_MAX_BYTES) + if (!chunk.data) { + return + } + if (!preHandlerPtyData.has(ptyId) && preHandlerPtyData.size >= PRE_HANDLER_PTY_DATA_MAX_PTYS) { + const oldestPtyId = preHandlerPtyData.keys().next().value + if (typeof oldestPtyId === 'string') { + preHandlerPtyData.delete(oldestPtyId) + } + } + const bufferedMeta = + meta && chunk.data.length !== data.length && typeof meta.rawLength === 'number' + ? { ...meta, rawLength: chunk.bytes } + : meta + const chunks = preHandlerPtyData.get(ptyId) ?? [] + chunks.push({ + data: chunk.data, + bytes: chunk.bytes, + ...(bufferedMeta ? { meta: bufferedMeta } : {}) + }) + let totalBytes = chunks.reduce((total, entry) => total + entry.bytes, 0) + while (totalBytes > PRE_HANDLER_PTY_DATA_MAX_BYTES && chunks.length > 1) { + totalBytes -= chunks.shift()?.bytes ?? 0 + } + preHandlerPtyData.set(ptyId, chunks) +} + +export function drainPreHandlerPtyData( + ptyId: string, + handler: (data: string, meta?: PtyDataMeta) => void +): void { + const chunks = preHandlerPtyData.get(ptyId) + if (!chunks) { + return + } + preHandlerPtyData.delete(ptyId) + for (const chunk of chunks) { + handler(chunk.data, chunk.meta) + } +} + +export function bufferPreHandlerPtyExit(ptyId: string, code: number): void { + preHandlerPtyExit.set(ptyId, code) +} + +export function drainPreHandlerPtyExit(ptyId: string, handler: (code: number) => void): void { + const code = preHandlerPtyExit.get(ptyId) + if (code === undefined) { + return + } + preHandlerPtyExit.delete(ptyId) + handler(code) +} + +export function clearPreHandlerPtyData(ptyId: string): void { + preHandlerPtyData.delete(ptyId) +} + +export function clearPreHandlerPtyState(ptyId: string): void { + preHandlerPtyData.delete(ptyId) + preHandlerPtyExit.delete(ptyId) +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 79049d62997..43aa4b8c1d1 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -21,6 +21,7 @@ import { ensurePtyDispatcher, getEagerPtyBufferHandle } from './pty-dispatcher' +import { drainPreHandlerPtyData, drainPreHandlerPtyExit } from './pty-pre-handler-buffer' import type { PtyTransport, IpcPtyTransportOptions, @@ -511,7 +512,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra storedCallbacks.onData?.(data) } }) - ptyDataHandlers.set(id, (data, meta) => { + const dataHandler = (data: string, meta?: PtyDataMeta): void => { outputProcessor.processData( data, storedCallbacks, @@ -521,7 +522,9 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra }, meta ) - }) + } + ptyDataHandlers.set(id, dataHandler) + drainPreHandlerPtyData(id, dataHandler) } function clearAccumulatedState(): void { @@ -638,7 +641,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } function registerPtyExitHandler(id: string): void { - ptyExitHandlers.set(id, (code) => { + const exitHandler = (code: number): void => { clearAccumulatedState() connected = false ptyId = null @@ -646,7 +649,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra storedCallbacks.onExit?.(code) storedCallbacks.onDisconnect?.() onPtyExit?.(id) - }) + } + ptyExitHandlers.set(id, exitHandler) // Why: shutdownWorktreeTerminals bypasses the transport layer — it // kills PTYs directly via IPC without calling disconnect()/destroy(). // This teardown callback lets unregisterPtyDataHandlers cancel @@ -654,6 +658,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra // would otherwise fire stale notifications after the data handler // is removed but before the exit event arrives. ptyTeardownHandlers.set(id, clearAccumulatedState) + drainPreHandlerPtyExit(id, exitHandler) } return { @@ -713,6 +718,9 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra registerPtyDataHandler(spawnResult.id) registerPtyExitHandler(spawnResult.id) + if (!connected || ptyId !== spawnResult.id) { + return undefined + } storedCallbacks.onConnect?.() storedCallbacks.onStatus?.('shell') @@ -790,6 +798,9 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra // recency sort order that reconnectPersistedTerminals preserved. registerPtyDataHandler(id) registerPtyExitHandler(id) + if (!connected || ptyId !== id) { + return + } // Why: hidden automation PTYs may have already rendered their TUI into // the eager buffer. Clear stale pane contents before replaying that diff --git a/src/renderer/src/lib/agent-draft-readiness.ts b/src/renderer/src/lib/agent-draft-readiness.ts index 896b748c7b3..ff7312f0818 100644 --- a/src/renderer/src/lib/agent-draft-readiness.ts +++ b/src/renderer/src/lib/agent-draft-readiness.ts @@ -1,6 +1,6 @@ import type { DraftPasteReadySignal } from '../../../shared/tui-agent-config' import type { GlobalSettings } from '../../../shared/types' -import { subscribeToPtyData } from '@/components/terminal-pane/pty-dispatcher' +import { subscribeToPtyData } from '@/components/terminal-pane/pty-data-sidecar-subscriptions' import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-stream' diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index 6a949e10c2e..ae687021224 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -35,7 +35,7 @@ vi.mock('@/store', () => ({ } })) -vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ +vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ subscribeToPtyData: testState.subscribeToPtyData })) diff --git a/src/renderer/src/lib/automation-session-observer.ts b/src/renderer/src/lib/automation-session-observer.ts index aa9bb108098..c80ae9156e8 100644 --- a/src/renderer/src/lib/automation-session-observer.ts +++ b/src/renderer/src/lib/automation-session-observer.ts @@ -1,4 +1,5 @@ -import { subscribeToPtyData, subscribeToPtyExit } from '@/components/terminal-pane/pty-dispatcher' +import { subscribeToPtyData } from '@/components/terminal-pane/pty-data-sidecar-subscriptions' +import { subscribeToPtyExit } from '@/components/terminal-pane/pty-dispatcher' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getRemoteRuntimeTerminalMultiplexer } from '@/runtime/remote-runtime-terminal-multiplexer' import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 11bbcfb170a..eaadb4cb4c0 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -89,10 +89,13 @@ vi.mock('@/lib/agent-paste-draft', () => ({ vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ registerEagerPtyBuffer: mockRegisterEagerPtyBuffer, - subscribeToPtyData: mockSubscribeToPtyData, subscribeToPtyExit: mockSubscribeToPtyExit })) +vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ + subscribeToPtyData: mockSubscribeToPtyData +})) + describe('launchAgentBackgroundSession', () => { beforeEach(() => { clearRuntimeCompatibilityCacheForTests() diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index ba4e7f033f5..d289ad86e36 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -16,9 +16,9 @@ import type { LaunchSource } from '../../../shared/telemetry-events' import { makePaneKey } from '../../../shared/stable-pane-id' import { registerEagerPtyBuffer, - subscribeToPtyData, subscribeToPtyExit } from '@/components/terminal-pane/pty-dispatcher' +import { subscribeToPtyData } from '@/components/terminal-pane/pty-data-sidecar-subscriptions' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' From c6d11efbf2d5f72da0489e6ac525516ba36b105d Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Sun, 21 Jun 2026 23:49:43 -0700 Subject: [PATCH 016/282] Make PR comments sidebar easier to triage and read (#5996) Co-authored-by: Cursor Co-authored-by: Orca Co-authored-by: brennanb2025 --- .../checks-panel-content.test.tsx | 27 + .../right-sidebar/checks-panel-content.tsx | 692 ++++++++++++------ .../pr-comment-presentation.test.ts | 40 + .../right-sidebar/pr-comment-presentation.ts | 232 ++++++ .../pr-comments-list-selection.test.tsx | 74 +- .../pr-comments-list-selection.ts | 10 +- src/renderer/src/i18n/locales/en.json | 11 +- src/renderer/src/i18n/locales/es.json | 11 +- src/renderer/src/i18n/locales/ja.json | 11 +- src/renderer/src/i18n/locales/ko.json | 11 +- src/renderer/src/i18n/locales/zh.json | 11 +- .../src/lib/pr-comment-action-state.test.ts | 71 ++ .../src/lib/pr-comment-action-state.ts | 58 ++ src/renderer/src/lib/pr-comment-time.test.ts | 19 + src/renderer/src/lib/pr-comment-time.ts | 29 + .../helpers/pr-comments-sidebar-fixture.ts | 142 ++++ tests/e2e/pr-comments-sidebar-cards.spec.ts | 185 +++++ 17 files changed, 1375 insertions(+), 259 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/pr-comment-presentation.test.ts create mode 100644 src/renderer/src/components/right-sidebar/pr-comment-presentation.ts create mode 100644 src/renderer/src/lib/pr-comment-action-state.test.ts create mode 100644 src/renderer/src/lib/pr-comment-action-state.ts create mode 100644 src/renderer/src/lib/pr-comment-time.test.ts create mode 100644 src/renderer/src/lib/pr-comment-time.ts create mode 100644 tests/e2e/helpers/pr-comments-sidebar-fixture.ts create mode 100644 tests/e2e/pr-comments-sidebar-cards.spec.ts diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx index 307c20ecf76..5ad62f79f25 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx @@ -278,6 +278,33 @@ describe('PRCommentsList', () => { expect(markup).not.toContain('Add a PR comment') }) + it('shows resolve on open review threads', () => { + const comments: PRComment[] = [ + { + id: 2, + author: 'alice', + authorAvatarUrl: '', + body: 'Please address this before merge.', + createdAt: '2026-05-14T00:00:00Z', + url: 'https://github.com/acme/widgets/pull/42#discussion_r2', + threadId: 'thread-open', + path: 'src/a.ts', + isResolved: false + } + ] + + const markup = renderWithTooltips( + React.createElement(PRCommentsList, { + comments, + commentsLoading: false, + onResolve: () => true + }) + ) + + expect(markup).toContain('Resolve') + expect(markup).not.toContain('Unresolve') + }) + it('renders a more-actions menu on conversation comments', () => { const comments: PRComment[] = [ { diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx index 938ad6ff8bf..5772c80a07a 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -22,6 +22,7 @@ import { AlertTriangle, MoreHorizontal, Pencil, + SlidersHorizontal, Trash, X } from 'lucide-react' @@ -39,9 +40,18 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger +} from '@/components/ui/context-menu' import { cn } from '@/lib/utils' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { @@ -53,16 +63,24 @@ import { type PRCommentAudienceFilter } from '@/lib/pr-comment-audience' import { - getPRCommentGroupCount, getPRCommentGroupId, getPRCommentGroupRoot, groupPRComments, - isResolvedPRCommentGroup, - PR_COMMENT_OPEN_AUTHOR_CLASS, - PR_COMMENT_RESOLVED_AUTHOR_CLASS, - PR_COMMENT_RESOLVED_CONTAINER_CLASS, type PRCommentGroup } from '@/lib/pr-comment-groups' +import { + getPRCommentGroupActionState, + isPRCommentGroupQueueableForAI, + partitionPRCommentGroupsForTriage, + sortPRCommentGroupsForTimeline, + type PRCommentGroupActionState +} from '@/lib/pr-comment-action-state' +import { formatPrCommentRelativeTime } from '@/lib/pr-comment-time' +import { + getPRCommentPresentationClasses, + getPRCommentGroupSurfaceClasses, + type PRCommentPresentationClasses +} from './pr-comment-presentation' import type { PRInfo, PRCheckDetail, @@ -83,6 +101,16 @@ import { useAppStore } from '@/store' export const PullRequestIcon = GitPullRequest +type PRCommentsListDisplayMode = 'triage' | 'timeline' + +const PR_COMMENT_LIST_DISPLAY_MODES: PRCommentsListDisplayMode[] = ['triage', 'timeline'] + +function getPRCommentsListDisplayModeLabel(mode: PRCommentsListDisplayMode): string { + return mode === 'triage' + ? translate('auto.components.right.sidebar.checks.panel.content.8a621a2c4f', 'Grouped') + : translate('auto.components.right.sidebar.checks.panel.content.b13f85d75c', 'Timeline') +} + export const CHECK_ICON: Record> = { success: CircleCheck, failure: CircleX, @@ -1435,16 +1463,19 @@ export function isMutablePRConversationComment(comment: PRComment): boolean { function CommentMoreMenu({ comment, onStartEdit, - onDelete + onDelete, + onQueueForAgent }: { comment: PRComment onStartEdit?: () => void onDelete?: () => void | Promise + onQueueForAgent?: () => void }): React.JSX.Element | null { const hasGoToComment = Boolean(comment.url) const hasEdit = Boolean(onStartEdit) const hasDelete = Boolean(onDelete) - if (!hasGoToComment && !hasEdit && !hasDelete) { + const hasQueue = Boolean(onQueueForAgent) + if (!hasGoToComment && !hasEdit && !hasDelete && !hasQueue) { return null } @@ -1465,6 +1496,21 @@ function CommentMoreMenu({ + {hasQueue ? ( + { + event.preventDefault() + onQueueForAgent?.() + }} + > + + {translate( + 'auto.components.right.sidebar.checks.panel.content.f8a2c91d04', + 'Queue for agent' + )} + + ) : null} + {hasQueue && (hasGoToComment || hasEdit || hasDelete) ? : null} {hasGoToComment && ( window.api.shell.openUrl(comment.url)}> @@ -1507,6 +1553,39 @@ function buildCopyText(comment: PRComment): string { return `File: ${location}\n\n${comment.body}` } +function PRCommentActionBadge({ + actionState, + isQueued, + presentation +}: { + actionState: PRCommentGroupActionState + isQueued: boolean + presentation: PRCommentPresentationClasses +}): React.JSX.Element | null { + if (isQueued) { + return ( + + {translate('auto.components.right.sidebar.checks.panel.content.b4e8a1c902', 'Queued')} + + ) + } + if (actionState === 'open') { + return ( + + {translate('auto.components.right.sidebar.checks.panel.content.7c1f0a2b11', 'Open')} + + ) + } + if (actionState === 'resolved') { + return ( + + {translate('auto.components.right.sidebar.checks.panel.content.8987d5a3dd', 'Resolved')} + + ) + } + return null +} + /** A single comment row — used for both root and reply comments. */ function CommentRow({ comment, @@ -1514,26 +1593,32 @@ function CommentRow({ showResolve, showReply, selectionControl, - resolveSelectionAction, + actionState, + isQueued, replyDisabled, replyDisabledReason, + presentation, onResolve, onReply, onEditComment, - onDeleteComment + onDeleteComment, + onQueueForAgent }: { comment: PRComment isReply: boolean showResolve: boolean showReply?: boolean selectionControl?: React.ReactNode - resolveSelectionAction?: React.ReactNode + actionState: PRCommentGroupActionState + isQueued: boolean replyDisabled?: boolean replyDisabledReason?: string + presentation: PRCommentPresentationClasses onResolve?: (threadId: string, resolve: boolean) => boolean | Promise onReply?: (comment: PRComment) => void onEditComment?: (comment: PRComment, body: string) => Promise onDeleteComment?: (comment: PRComment) => void | Promise + onQueueForAgent?: () => void }): React.JSX.Element { const automated = isBotPRComment(comment) const canMutateComment = isMutablePRConversationComment(comment) @@ -1588,94 +1673,159 @@ function CommentRow({ const trimmedDraft = draft.trim() const canSaveEdit = !submittingEdit && trimmedDraft.length > 0 && trimmedDraft !== comment.body + const relativeTime = formatPrCommentRelativeTime(comment.createdAt, Date.now()) + + const authorAvatar = comment.authorAvatarUrl ? ( + {comment.author} + ) : ( +
+ ) + + const authorName = ( + + {comment.author} + + ) + const authorLead = selectionControl ? ( + {selectionControl} + ) : ( + authorAvatar + ) + + const commentActions = !editing ? ( +
+ {showResolve && + comment.threadId != null && + onResolve && + (actionState === 'open' || actionState === 'resolved') && ( + + )} + {showReply && onReply && ( + + )} + + +
+ ) : null + + const cardMetaRow = + presentation.useCardLayout && !isReply ? ( +
+ {relativeTime ? {relativeTime} : null} + {automated ? ( + + {translate('auto.components.right.sidebar.checks.panel.content.2ba0a32bdd', 'bot')} + + ) : null} + {comment.path ? ( + + {comment.path.split('/').pop()} + {formatLineRange(comment) && `:${formatLineRange(comment)}`} + + ) : null} + +
+ ) : null + + const authorLine = + presentation.useCardLayout && !isReply ? ( + <> +
+ {authorLead} + {authorName} + {commentActions} +
+ {cardMetaRow} + + ) : ( + <> + {authorLead} + {authorName} + {relativeTime ? ( + + {presentation.useCardLayout ? `· ${relativeTime}` : relativeTime} + + ) : null} + {automated && ( + + {translate('auto.components.right.sidebar.checks.panel.content.2ba0a32bdd', 'bot')} + + )} + {!isReply && comment.path && ( + + {comment.path.split('/').pop()} + {formatLineRange(comment) && `:${formatLineRange(comment)}`} + + )} + {!isReply ? ( + + ) : null} +
+ {commentActions} + + ) return (
- {selectionControl}
- {/* Author line: avatar + name + file badge aligned on center */} -
- {comment.authorAvatarUrl ? ( - {comment.author} - ) : ( -
- )} - - {comment.author} - - {automated && ( - - {translate('auto.components.right.sidebar.checks.panel.content.2ba0a32bdd', 'bot')} - - )} - {!isReply && comment.path && ( - - {comment.path.split('/').pop()} - {formatLineRange(comment) && `:${formatLineRange(comment)}`} - - )} -
- {!editing && resolveSelectionAction} - {!editing && ( -
- {showResolve && comment.threadId != null && onResolve && ( - - )} - {showReply && onReply && ( - - )} - - -
+
+ {authorLine}
{editing ? ( -
+