fix(cli): orca file open no longer moves your view unless you pass --focus (#24244)

* docs(cli): file open/diff/open-changed say they switch the user's view and are for user requests only

Refs #9944

* fix(cli): file open/diff/open-changed leave the user's view alone unless --focus

`orca file open`, `file diff` and `file open-changed` always switched the
desktop to the target worktree, selected the tab and revealed it in the
sidebar. An agent skill that opens its answer pulled the user out of whatever
they were typing in (#9944), and a phone opening a file moved the desktop too.

The commands now add the tab in its worktree without changing anything on
screen, including when that worktree is the one being viewed: the new tab is
added to the tab bar but the active tab, tab type and focus stay put. In a
worktree the user is not viewing, the tab becomes that worktree's selection so
it is in front when they go there. `--focus` keeps today's behavior.

files.open / files.openDiff take an optional `navigation` target (the existing
RUNTIME_NAVIGATION_TARGETS vocabulary); the CLI sends 'all' for --focus, like
`worktree create --activate`, and nothing otherwise. The renderer moves the
host view only when the target reaches the host; a missing field (phones,
older CLIs) leaves it still. Editor opens for a worktree other than the
on-screen one no longer write the global activeFileId/activeTabType.

Refs #9944

* test(cli): justify the window and runtime stubs in the file-open notification test

* fix(cli): keep phone file opens switching the desktop; the CLI asks for 'caller'

Phone opens send no `navigation` field, and the phone's diff-review "Open in
session" relies on the desktop selecting the diff it opened. A missing field
now keeps the original switch exactly; the CLI says what it wants instead:
'caller' (no host move) by default and 'all' for --focus. Older CLIs, which
send nothing, keep switching as they always have.

Refs #9944

* fix(cli): background file opens select the tab without counting as a visit

A CLI open into a worktree the user is not viewing selected the new tab with
the same activation a user click uses, which stamps lastFocusedAt and the
group's recency list. The worktree jump palette sorts recent tabs by that
time, so every agent `orca file open` into another worktree jumped to the top
of the user's recent tabs.

Editor opens now take a selection mode: 'focus' (default, unchanged),
'background' (select within its worktree without recording focus or recency)
and 'none' (add only). createUnifiedTab and activateTab gain recordFocus:false
for the background case.

Also: tests for reopening an already-open file or diff without --focus, a
comment that file opens move only the host window ('all' acts as 'host'),
root help lines back under 100 columns, and an accurate remote test title.

Refs #9944

* fix(tabs): a background-selected tab still joins its group's tab history

recordFocus:false skipped both the focus-time stamp and the group's
recentTabIds append while still making the tab the group's active tab. Ctrl+Tab
looks the active tab up in that history, so after a background CLI open it
did nothing (or went to the wrong tab) once the user switched to that
worktree, and hydrate kept the broken history across a restart.

Only the focus-time stamp is skipped now; the jump palette's recent rows sort
by that alone, so the palette fix stands.

Refs #9944

* fix(cli): file open/diff/open-changed --focus help says it brings the user to the file

The three commands borrowed the shared --focus line written for terminal
create ("Reveal the created terminal session in Orca"). They now use the
per-command flag help table; terminal create's line is unchanged.

Refs #9944
This commit is contained in:
Brennan Benson
2026-09-30 20:46:47 -07:00
committed by GitHub
parent 78daf71268
commit a4606ccae3
40 changed files with 1045 additions and 123 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ For tracked multi-agent work, use [Orchestration](/docs/cli/orchestration) inste
## File commands
Open files and diffs in the active Orca worktree from a shell:
Open files and diffs as tabs in their Orca worktree from a shell. They open without changing what you are looking at; add `--focus` to be brought to the file:
```
orca file open src/App.tsx
+1 -1
View File
@@ -158,7 +158,7 @@ orca file diff src/App.tsx --staged --worktree active --json
orca file open-changed --mode both --worktree active --json
```
Paths are relative to the selected worktree. `open-changed` reads git status and opens changed files in edit, diff, or both modes.
Paths are relative to the selected worktree. `open-changed` reads git status and opens changed files in edit, diff, or both modes. Tabs open without changing what you are looking at; pass `--focus` to be brought to them.
## Built-in browser
+13
View File
@@ -1,3 +1,7 @@
// Why: the shared --focus line describes terminal create's terminal session.
const FILE_OPEN_FOCUS_HELP =
"--focus Bring the user to the file (switches Orca's window to its worktree)"
/** Per-command flag help, kept out of the shared help chain it would crowd. */
const COMMAND_SCOPED_FLAG_HELP: Record<string, Record<string, string>> = {
'skills get': {
@@ -5,6 +9,15 @@ const COMMAND_SCOPED_FLAG_HELP: Record<string, Record<string, string>> = {
reference: '--reference <name> Print one bundled reference by name',
references: '--references List the bundled reference names for a topic'
},
'file open': {
focus: FILE_OPEN_FOCUS_HELP
},
'file diff': {
focus: FILE_OPEN_FOCUS_HELP
},
'file open-changed': {
focus: FILE_OPEN_FOCUS_HELP
},
'skills install': {
agent: '--agent <names> Comma-separated install targets; default is detected agents'
},
+10 -5
View File
@@ -81,7 +81,8 @@ describe('absolute file CLI paths', () => {
})
expect(callMock).toHaveBeenNthCalledWith(3, 'files.open', {
worktree: 'id:repo::/root/orca/workspaces/xxx',
relativePath: 'xxx/xxx.ts'
relativePath: 'xxx/xxx.ts',
navigation: 'caller'
})
})
@@ -105,7 +106,8 @@ describe('absolute file CLI paths', () => {
expect(process.exitCode).toBeUndefined()
expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', {
worktree: 'id:wt-1',
relativePath
relativePath,
navigation: 'caller'
})
}
@@ -267,7 +269,8 @@ describe('absolute file CLI paths', () => {
expect(callMock).toHaveBeenNthCalledWith(2, 'files.openDiff', {
worktree: 'id:wt-1',
relativePath: 'src/App.tsx',
staged: true
staged: true,
navigation: 'caller'
})
})
@@ -287,7 +290,8 @@ describe('absolute file CLI paths', () => {
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('files.open', {
worktree: 'id:wt-1',
relativePath: 'src/App.tsx'
relativePath: 'src/App.tsx',
navigation: 'caller'
})
})
@@ -308,7 +312,8 @@ describe('absolute file CLI paths', () => {
expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', {
worktree: 'id:wt-1',
relativePath: absolutePath
relativePath: absolutePath,
navigation: 'caller'
})
})
+153 -11
View File
@@ -82,7 +82,8 @@ describe('orca file CLI handlers', () => {
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', {
worktree: 'id:repo::/tmp/repo',
relativePath: 'src/App.tsx'
relativePath: 'src/App.tsx',
navigation: 'caller'
})
expect(vi.mocked(console.log).mock.calls[0][0]).toBe('Opened src/App.tsx.')
})
@@ -107,7 +108,8 @@ describe('orca file CLI handlers', () => {
expect(callMock).toHaveBeenCalledWith('files.openDiff', {
worktree: 'id:wt-1',
relativePath: 'src/App.tsx',
staged: true
staged: true,
navigation: 'caller'
})
})
@@ -127,7 +129,8 @@ describe('orca file CLI handlers', () => {
expect(callMock).toHaveBeenCalledWith('files.openDiff', {
worktree: 'id:wt-1',
relativePath: 'assets/logo.png',
staged: false
staged: false,
navigation: 'caller'
})
expect(vi.mocked(console.log).mock.calls[0][0]).toBe(
'Did not open diff for assets/logo.png: binary file.'
@@ -186,17 +189,20 @@ describe('orca file CLI handlers', () => {
expect(callMock).toHaveBeenNthCalledWith(3, 'files.openDiff', {
worktree: 'id:repo::/tmp/repo',
relativePath: 'src/App.tsx',
staged: false
staged: false,
navigation: 'caller'
})
expect(callMock).toHaveBeenNthCalledWith(4, 'files.openDiff', {
worktree: 'id:repo::/tmp/repo',
relativePath: 'package.json',
staged: true
staged: true,
navigation: 'caller'
})
expect(callMock).toHaveBeenNthCalledWith(5, 'files.openDiff', {
worktree: 'id:repo::/tmp/repo',
relativePath: 'docs/new.md',
staged: false
staged: false,
navigation: 'caller'
})
expect(vi.mocked(console.log).mock.calls[0][0]).toBe('Opened 3 changed file targets.')
})
@@ -263,7 +269,8 @@ describe('orca file CLI handlers', () => {
expect(callMock).toHaveBeenNthCalledWith(2, 'files.openDiff', {
worktree: 'id:wt-1',
relativePath: 'src/App.tsx',
staged: true
staged: true,
navigation: 'caller'
})
const output = vi.mocked(console.log).mock.calls[0][0]
expect(output).toContain('Opened 1 changed file targets.')
@@ -317,28 +324,163 @@ describe('orca file CLI handlers', () => {
expect(callMock).toHaveBeenNthCalledWith(1, 'git.status', { worktree: 'id:wt-1' })
expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', {
worktree: 'id:wt-1',
relativePath: 'src/App.tsx'
relativePath: 'src/App.tsx',
navigation: 'caller'
})
expect(callMock).toHaveBeenNthCalledWith(3, 'files.openDiff', {
worktree: 'id:wt-1',
relativePath: 'src/App.tsx',
staged: false
staged: false,
navigation: 'caller'
})
expect(callMock).toHaveBeenNthCalledWith(4, 'files.openDiff', {
worktree: 'id:wt-1',
relativePath: 'src/App.tsx',
staged: true
staged: true,
navigation: 'caller'
})
expect(callMock).toHaveBeenNthCalledWith(5, 'files.openDiff', {
worktree: 'id:wt-1',
relativePath: 'docs/old.md',
staged: false
staged: false,
navigation: 'caller'
})
const output = vi.mocked(console.log).mock.calls[0][0]
expect(output).toContain('Opened 4 changed file targets.')
expect(output).toContain('docs/old.md: deleted file has no edit target')
})
it('asks the host to move its view only when --focus is passed', async () => {
const opened = { worktree: 'wt-1', relativePath: 'src/App.tsx', kind: 'text', opened: true }
queueFixtures(
callMock,
okFixture('req_open', opened),
okFixture('req_open_focus', opened),
okFixture('req_diff', opened),
okFixture('req_diff_focus', opened)
)
await main(['file', 'open', 'src/App.tsx', '--worktree', 'id:wt-1'], '/tmp/elsewhere')
await main(
['file', 'open', 'src/App.tsx', '--worktree', 'id:wt-1', '--focus'],
'/tmp/elsewhere'
)
await main(['file', 'diff', 'src/App.tsx', '--worktree', 'id:wt-1'], '/tmp/elsewhere')
await main(
['file', 'diff', 'src/App.tsx', '--worktree', 'id:wt-1', '--focus'],
'/tmp/elsewhere'
)
expect(callMock).toHaveBeenNthCalledWith(1, 'files.open', {
worktree: 'id:wt-1',
relativePath: 'src/App.tsx',
navigation: 'caller'
})
expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', {
worktree: 'id:wt-1',
relativePath: 'src/App.tsx',
navigation: 'all'
})
expect(callMock).toHaveBeenNthCalledWith(3, 'files.openDiff', {
worktree: 'id:wt-1',
relativePath: 'src/App.tsx',
staged: false,
navigation: 'caller'
})
expect(callMock).toHaveBeenNthCalledWith(4, 'files.openDiff', {
worktree: 'id:wt-1',
relativePath: 'src/App.tsx',
staged: false,
navigation: 'all'
})
})
it('sends caller to a paired remote server, and all with --focus', async () => {
const opened = { worktree: 'wt-1', relativePath: 'src/App.tsx', kind: 'text', opened: true }
queueFixtures(callMock, okFixture('req_open', opened), okFixture('req_open_focus', opened))
const remote = ['--worktree', 'id:wt-1', '--pairing-code', 'remote-runtime']
await main(['file', 'open', 'src/App.tsx', ...remote], '/tmp/elsewhere')
await main(['file', 'open', 'src/App.tsx', ...remote, '--focus'], '/tmp/elsewhere')
expect(callMock).toHaveBeenNthCalledWith(1, 'files.open', {
worktree: 'id:wt-1',
relativePath: 'src/App.tsx',
navigation: 'caller'
})
expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', {
worktree: 'id:wt-1',
relativePath: 'src/App.tsx',
navigation: 'all'
})
})
it('moves the view once for open-changed --focus, on the first tab that opens', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
entries: [
{ path: 'assets/logo.png', status: 'modified', area: 'unstaged' },
{ path: 'src/App.tsx', status: 'modified', area: 'unstaged' }
],
conflictOperation: 'unknown'
}),
okFixture('req_open_binary', {
worktree: 'wt-1',
relativePath: 'assets/logo.png',
kind: 'binary',
opened: false
}),
okFixture('req_diff_1', {
worktree: 'wt-1',
relativePath: 'assets/logo.png',
kind: 'binary',
opened: true
}),
okFixture('req_open', {
worktree: 'wt-1',
relativePath: 'src/App.tsx',
kind: 'text',
opened: true
}),
okFixture('req_diff_2', {
worktree: 'wt-1',
relativePath: 'src/App.tsx',
kind: 'text',
opened: true
})
)
await main(
['file', 'open-changed', '--mode', 'both', '--worktree', 'id:wt-1', '--focus'],
'/tmp/elsewhere'
)
// Why: a binary edit open returns opened:false before reaching the host, so focus carries to the next open.
expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', {
worktree: 'id:wt-1',
relativePath: 'assets/logo.png',
navigation: 'all'
})
expect(callMock).toHaveBeenNthCalledWith(3, 'files.openDiff', {
worktree: 'id:wt-1',
relativePath: 'assets/logo.png',
staged: false,
navigation: 'all'
})
expect(callMock).toHaveBeenNthCalledWith(4, 'files.open', {
worktree: 'id:wt-1',
relativePath: 'src/App.tsx',
navigation: 'caller'
})
expect(callMock).toHaveBeenNthCalledWith(5, 'files.openDiff', {
worktree: 'id:wt-1',
relativePath: 'src/App.tsx',
staged: false,
navigation: 'caller'
})
})
it('requires an explicit worktree for remote file commands', async () => {
const priorExitCode = process.exitCode
+37 -8
View File
@@ -1,5 +1,6 @@
import type { GitStatusEntry, GitStatusResult } from '../../shared/git-status-types'
import type { RuntimeFileOpenResult, RuntimeWorktreeRecord } from '../../shared/runtime-types'
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
import { isRuntimePathAbsolute, relativePathInsideRoot } from '../../shared/cross-platform-path'
import { isWslUncPath, parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths'
import type { CommandHandler, HandlerContext } from '../dispatch'
@@ -116,6 +117,13 @@ function getOpenChangedMode(flags: Map<string, string | boolean>): OpenChangedMo
throw new RuntimeClientError('invalid_argument', 'Invalid --mode. Use edit, diff, or both.')
}
// Why: the CLI has no view of its own, so 'caller' moves nothing. --focus sends 'all', but unlike
// worktree create --activate a file open moves only the host's own window (intended; paired
// clients are not navigated). Hosts treat a missing field as the legacy switch.
function getFileOpenNavigation(flags: Map<string, string | boolean>): RuntimeNavigationTarget {
return flags.get('focus') === true ? 'all' : 'caller'
}
function canOpenEntryForEdit(entry: GitStatusEntry): string | null {
if (entry.status === 'deleted') {
return 'deleted file has no edit target'
@@ -129,11 +137,13 @@ function canOpenEntryForEdit(entry: GitStatusEntry): string | null {
async function openFileEdit(
ctx: HandlerContext,
worktree: string,
path: string
path: string,
navigation: RuntimeNavigationTarget
): Promise<FileOpenRecord> {
const result = await ctx.client.call<RuntimeFileOpenResult>('files.open', {
worktree,
relativePath: path
relativePath: path,
navigation
})
return {
path,
@@ -148,12 +158,14 @@ async function openFileDiff(
ctx: HandlerContext,
worktree: string,
path: string,
staged: boolean
staged: boolean,
navigation: RuntimeNavigationTarget
): Promise<FileOpenRecord> {
const result = await ctx.client.call<RuntimeFileOpenResult>('files.openDiff', {
worktree,
relativePath: path,
staged
staged,
navigation
})
return {
path,
@@ -198,7 +210,8 @@ export const FILE_HANDLERS: Record<string, CommandHandler> = {
const relativePath = await resolveFilePath(ctx, worktree, path)
const result = await ctx.client.call<RuntimeFileOpenResult>('files.open', {
worktree,
relativePath
relativePath,
navigation: getFileOpenNavigation(ctx.flags)
})
printResult(result, ctx.json, formatFileOpen)
},
@@ -210,7 +223,8 @@ export const FILE_HANDLERS: Record<string, CommandHandler> = {
const result = await ctx.client.call<RuntimeFileOpenResult>('files.openDiff', {
worktree,
relativePath,
staged
staged,
navigation: getFileOpenNavigation(ctx.flags)
})
printResult(result, ctx.json, formatFileDiff)
},
@@ -221,6 +235,17 @@ export const FILE_HANDLERS: Record<string, CommandHandler> = {
const opened: FileOpenRecord[] = []
const skipped: FileOpenRecord[] = []
const openedEditPaths = new Set<string>()
// Why: switch once — the first tab that actually opens carries --focus; later ones join the now-viewed worktree.
let navigation = getFileOpenNavigation(ctx.flags)
const openWithFocusOnce = async (
open: () => Promise<FileOpenRecord>
): Promise<FileOpenRecord> => {
const record = await open()
if (record.opened) {
navigation = 'caller'
}
return record
}
for (const entry of status.result.entries) {
if (mode === 'edit' || mode === 'both') {
@@ -235,7 +260,9 @@ export const FILE_HANDLERS: Record<string, CommandHandler> = {
})
} else if (!openedEditPaths.has(entry.path)) {
openedEditPaths.add(entry.path)
const record = await openFileEdit(ctx, worktree, entry.path)
const record = await openWithFocusOnce(() =>
openFileEdit(ctx, worktree, entry.path, navigation)
)
const records = record.opened ? opened : skipped
records.push(record)
}
@@ -253,7 +280,9 @@ export const FILE_HANDLERS: Record<string, CommandHandler> = {
reason: 'unresolved conflict may not have a single diff target'
})
} else {
const record = await openFileDiff(ctx, worktree, entry.path, staged)
const record = await openWithFocusOnce(() =>
openFileDiff(ctx, worktree, entry.path, staged, navigation)
)
const records = record.opened ? opened : skipped
records.push(record)
}
+3 -3
View File
@@ -76,9 +76,9 @@ export const ROOT_HELP_TEXT_PRIMARY = [
' worktree ps Show a compact orchestration summary across worktrees',
'',
'Files:',
' file open Open a workspace file in the Orca editor',
' file diff Open a workspace file diff in the Orca editor',
' file open-changed Open all git-changed files for a workspace',
' file open Open a file as a tab, view stays put (--focus brings you to it)',
' file diff Open a file diff as a tab, view stays put (--focus brings you to it)',
' file open-changed Open changed files as tabs, view stays put (--focus brings you there)',
'',
'Terminals:',
' terminal list List live Orca-managed terminals',
+3 -3
View File
@@ -56,9 +56,9 @@ export const ROOT_HELP_TEXT_SECONDARY = [
' orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--linear-issue <identifier-or-url|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]',
' orca worktree rm --worktree <selector> [--force] [--run-hooks] [--allow-failed-archive-hook] [--json]',
' orca worktree ps [--limit <n>] [--json]',
' orca file open <path> [--worktree <selector>] [--json]',
' orca file diff <path> [--staged] [--worktree <selector>] [--json]',
' orca file open-changed [--mode edit|diff|both] [--worktree <selector>] [--json]',
' orca file open <path> [--worktree <selector>] [--focus] [--json]',
' orca file diff <path> [--staged] [--worktree <selector>] [--focus] [--json]',
' orca file open-changed [--mode edit|diff|both] [--worktree <selector>] [--focus] [--json]',
' orca terminal list [--worktree <selector>] [--limit <n>] [--include-visual-layouts] [--json]',
' orca terminal show [--terminal <handle>] [--json]',
' orca terminal read [--terminal <handle>] [--cursor <n>] [--limit <n>] [--json]',
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { findCommandSpec } from '../args'
import { formatCommandHelp } from '../help'
import { COMMAND_SPECS } from './index'
function optionLine(path: string[], flag: string): string | undefined {
const spec = findCommandSpec(COMMAND_SPECS, path)
if (!spec) {
throw new Error(`no spec for ${path.join(' ')}`)
}
return formatCommandHelp(spec)
.split('\n')
.find((line) => line.trimStart().startsWith(`--${flag} `))
}
describe('orca file command help', () => {
it.each([['open'], ['diff'], ['open-changed']])(
'file %s describes --focus as bringing the user to the file',
(command) => {
expect(optionLine(['file', command], 'focus')).toBe(
" --focus Bring the user to the file (switches Orca's window to its worktree)"
)
}
)
it('leaves terminal create --focus describing its terminal session', () => {
expect(optionLine(['terminal', 'create'], 'focus')).toBe(
' --focus Reveal the created terminal session in Orca'
)
})
})
+17 -10
View File
@@ -4,25 +4,29 @@ import { GLOBAL_FLAGS } from '../args'
export const FILE_COMMAND_SPECS: CommandSpec[] = [
{
path: ['file', 'open'],
summary: 'Open a workspace file in the Orca editor',
usage: 'orca file open <path> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'path', 'worktree'],
summary:
'Open a file as a tab in its worktree without changing your view; --focus brings you to it',
usage: 'orca file open <path> [--worktree <selector>] [--focus] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'path', 'worktree', 'focus'],
positionalArgs: ['path'],
notes: [
'The file opens as a tab in that worktree without changing what you are looking at, even in the worktree on screen. --focus brings you to it; agents should pass --focus only when the user asked to see the file.',
'The path may be relative to the selected worktree or an absolute path inside that worktree. When --worktree is omitted, local CLI calls infer the current Orca worktree from cwd.'
],
examples: [
'orca file open src/App.tsx',
'orca file open --path docs/readme.md --worktree active'
'orca file open --path docs/readme.md --worktree active --focus'
]
},
{
path: ['file', 'diff'],
summary: 'Open a workspace file diff in the Orca editor',
usage: 'orca file diff <path> [--staged] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'path', 'staged', 'worktree'],
summary:
'Open a file diff as a tab in its worktree without changing your view; --focus brings you to it',
usage: 'orca file diff <path> [--staged] [--worktree <selector>] [--focus] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'path', 'staged', 'worktree', 'focus'],
positionalArgs: ['path'],
notes: [
'The file opens as a tab in that worktree without changing what you are looking at, even in the worktree on screen. --focus brings you to it; agents should pass --focus only when the user asked to see the file.',
'Diffs default to unstaged changes. Pass --staged to open the staged source-control diff.',
'The path may be relative to the selected worktree or an absolute path inside that worktree.'
],
@@ -33,10 +37,13 @@ export const FILE_COMMAND_SPECS: CommandSpec[] = [
},
{
path: ['file', 'open-changed'],
summary: 'Open all git-changed files for a workspace',
usage: 'orca file open-changed [--mode edit|diff|both] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'mode', 'worktree'],
summary:
'Open all git-changed files as tabs without changing your view; --focus brings you to them',
usage:
'orca file open-changed [--mode edit|diff|both] [--worktree <selector>] [--focus] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'mode', 'worktree', 'focus'],
notes: [
'The files open as tabs in that worktree without changing what you are looking at, even in the worktree on screen. --focus brings you to them; agents should pass --focus only when the user asked to see the files.',
'For v1, changed files come from git status for the selected worktree.',
'The default mode is diff. Edit mode skips deleted files because there is no file to open.'
],
+11 -4
View File
@@ -44,17 +44,24 @@ export class OrcaRuntimeWithFileCommands extends OrcaRuntimeWithPreservedBranchC
absolutePath
}),
resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector),
openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId) => {
openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId, navigation) => {
if (!this.notifier?.openFile) {
throw new Error('renderer_unavailable')
}
this.notifier.openFile(worktreeId, filePath, relativePath, runtimeEnvironmentId)
this.notifier.openFile(worktreeId, filePath, relativePath, runtimeEnvironmentId, navigation)
},
openDiff: (worktreeId, filePath, relativePath, staged, runtimeEnvironmentId) => {
openDiff: (worktreeId, filePath, relativePath, staged, runtimeEnvironmentId, navigation) => {
if (!this.notifier?.openDiff) {
throw new Error('renderer_unavailable')
}
this.notifier.openDiff(worktreeId, filePath, relativePath, staged, runtimeEnvironmentId)
this.notifier.openDiff(
worktreeId,
filePath,
relativePath,
staged,
runtimeEnvironmentId,
navigation
)
}
})
@@ -56,6 +56,7 @@ describe('RuntimeFileCommands', () => {
'/repo/docs/readme.md',
'docs/readme.md',
true,
undefined,
undefined
)
expect(result).toEqual({
@@ -78,6 +79,7 @@ describe('RuntimeFileCommands', () => {
'wt-1',
'/repo/docs/readme.md',
'docs/readme.md',
undefined,
undefined
)
expect(result).toEqual({
@@ -100,6 +102,7 @@ describe('RuntimeFileCommands', () => {
'wt-1',
'/repo/assets/logo.png',
'assets/logo.png',
undefined,
undefined
)
expect(result).toEqual({
@@ -110,6 +113,33 @@ describe('RuntimeFileCommands', () => {
})
})
it('passes the caller navigation target to the renderer host', async () => {
const openFile = vi.fn()
const openDiff = vi.fn()
const { commands } = createRuntimeFileCommands({ openFile, openDiff })
resolveAuthorizedPathMock.mockResolvedValue('/repo/docs/readme.md')
statMock.mockResolvedValue({ isDirectory: () => false })
await commands.openMobileFile('id:wt-1', 'docs/readme.md', 'all')
await commands.openMobileDiff('id:wt-1', 'docs/readme.md', false, 'host')
expect(openFile).toHaveBeenCalledWith(
'wt-1',
'/repo/docs/readme.md',
'docs/readme.md',
undefined,
'all'
)
expect(openDiff).toHaveBeenCalledWith(
'wt-1',
'/repo/docs/readme.md',
'docs/readme.md',
false,
undefined,
'host'
)
})
it('leaves non-previewable binaries unavailable on mobile', async () => {
const openFile = vi.fn()
const { commands } = createRuntimeFileCommands({ openFile })
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { FILE_METHODS } from './files'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
function createDispatcher(): {
runtime: Pick<OrcaRuntimeService, 'openMobileFile' | 'openMobileDiff'>
dispatcher: RpcDispatcher
} {
const opened = { worktree: 'wt-1', relativePath: 'a.ts', kind: 'text', opened: true }
const runtime = {
getRuntimeId: () => 'test-runtime',
openMobileFile: vi.fn().mockResolvedValue(opened),
openMobileDiff: vi.fn().mockResolvedValue(opened)
}
const dispatcher = new RpcDispatcher({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FILE_METHODS open handlers only read the stubbed members.
runtime: runtime as unknown as OrcaRuntimeService,
methods: FILE_METHODS
})
return { runtime, dispatcher }
}
describe('file open RPC navigation', () => {
it('passes an explicit navigation target through file and diff opens', async () => {
const { runtime, dispatcher } = createDispatcher()
await dispatcher.dispatch(
makeRequest('files.open', { worktree: 'id:wt-1', relativePath: 'a.ts', navigation: 'all' })
)
await dispatcher.dispatch(
makeRequest('files.openDiff', {
worktree: 'id:wt-1',
relativePath: 'a.ts',
navigation: 'host'
})
)
expect(runtime.openMobileFile).toHaveBeenCalledWith('id:wt-1', 'a.ts', 'all')
expect(runtime.openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'a.ts', false, 'host')
})
it('leaves navigation absent for callers that send none (phones, older CLIs)', async () => {
const { runtime, dispatcher } = createDispatcher()
await dispatcher.dispatch(
makeRequest('files.open', { worktree: 'id:wt-1', relativePath: 'a.ts' })
)
await dispatcher.dispatch(
makeRequest('files.openDiff', { worktree: 'id:wt-1', relativePath: 'a.ts', staged: true })
)
expect(runtime.openMobileFile).toHaveBeenCalledWith('id:wt-1', 'a.ts', undefined)
expect(runtime.openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'a.ts', true, undefined)
})
it('rejects an unknown navigation target on file opens', async () => {
const { runtime, dispatcher } = createDispatcher()
const response = await dispatcher.dispatch(
makeRequest('files.open', {
worktree: 'id:wt-1',
relativePath: 'a.ts',
navigation: 'everyone'
})
)
expect(response).toMatchObject({ ok: false })
expect(runtime.openMobileFile).not.toHaveBeenCalled()
})
})
+7 -2
View File
@@ -51,7 +51,7 @@ describe('file RPC methods', () => {
makeRequest('files.open', { worktree: 'id:wt-1', relativePath: 'docs/readme.md' })
)
expect(runtime.openMobileFile).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md')
expect(runtime.openMobileFile).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', undefined)
expect(response).toMatchObject({
ok: true,
result: { kind: 'markdown', opened: true }
@@ -78,7 +78,12 @@ describe('file RPC methods', () => {
})
)
expect(runtime.openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', true)
expect(runtime.openMobileDiff).toHaveBeenCalledWith(
'id:wt-1',
'docs/readme.md',
true,
undefined
)
expect(response).toMatchObject({
ok: true,
result: { kind: 'markdown', opened: true }
+9 -3
View File
@@ -11,6 +11,7 @@ import {
DocPreviewFileRead,
FileListAll,
FileOpenDiff,
FileOpenTab,
FilePathSearch,
FileReadChunk,
FileSearch,
@@ -56,15 +57,20 @@ export const FILE_METHODS = [
}),
defineMethod({
name: 'files.open',
params: FileOpen,
params: FileOpenTab,
handler: async (params, { runtime }) =>
runtime.openMobileFile(params.worktree, params.relativePath)
runtime.openMobileFile(params.worktree, params.relativePath, params.navigation)
}),
defineMethod({
name: 'files.openDiff',
params: FileOpenDiff,
handler: async (params, { runtime }) =>
runtime.openMobileDiff(params.worktree, params.relativePath, params.staged === true)
runtime.openMobileDiff(
params.worktree,
params.relativePath,
params.staged === true,
params.navigation
)
}),
defineMethod({
name: 'files.read',
@@ -6,6 +6,7 @@ import type {
} from './runtime-file-command-target'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { RuntimeNativeChatFileContext } from '../../shared/runtime-types'
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
import type { FsChangeEvent } from '../../shared/filesystem-entry-types'
import { PhysicalExitTracker } from '../../shared/physical-exit-tracker'
import {
@@ -52,14 +53,16 @@ export type RuntimeFileCommandHost = {
worktreeId: string,
filePath: string,
relativePath: string,
runtimeEnvironmentId?: string | null
runtimeEnvironmentId?: string | null,
navigation?: RuntimeNavigationTarget
): void
openDiff(
worktreeId: string,
filePath: string,
relativePath: string,
staged: boolean,
runtimeEnvironmentId?: string | null
runtimeEnvironmentId?: string | null,
navigation?: RuntimeNavigationTarget
): void
}
@@ -11,6 +11,7 @@ import {
} from './runtime-file-command-host'
import { basenameFromRelativePath } from './runtime-file-paths'
import type { RuntimeFileListResult, RuntimeFileOpenResult } from '../../shared/runtime-types'
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
import { listQuickOpenFiles } from '../ipc/filesystem-list-files'
import {
MOBILE_FILE_LIST_LIMIT,
@@ -155,7 +156,8 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA
async openMobileFile(
worktreeSelector: string,
relativePath: string
relativePath: string,
navigation?: RuntimeNavigationTarget
): Promise<RuntimeFileOpenResult> {
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const { worktree } = target
@@ -177,7 +179,7 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA
// Why: CLI/agents treat opened:true as success; stat first so missing paths fail the RPC instead of opening a ghost tab.
await this.assertMobileOpenTargetExists(filePath, runtimeFileRouteForTarget(target))
// Why: the internal runtimeId isn't a valid env selector; pass undefined so openFile falls back to activeRuntimeEnvironmentId.
this.host.openFile(worktree.id, filePath, relativePath, undefined)
this.host.openFile(worktree.id, filePath, relativePath, undefined, navigation)
return { worktree: worktree.id, relativePath, kind, opened: true }
}
@@ -203,7 +205,8 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA
async openMobileDiff(
worktreeSelector: string,
relativePath: string,
staged: boolean
staged: boolean,
navigation?: RuntimeNavigationTarget
): Promise<RuntimeFileOpenResult> {
const { worktree } = await this.host.resolveRuntimeFileTarget(worktreeSelector)
if (!isSafeMobileRelativePath(relativePath)) {
@@ -216,7 +219,7 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA
: 'text'
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
// Why: see openMobileFile; avoid stamping internal runtimeId as runtimeEnvironmentId.
this.host.openDiff(worktree.id, filePath, relativePath, staged, undefined)
this.host.openDiff(worktree.id, filePath, relativePath, staged, undefined, navigation)
return { worktree: worktree.id, relativePath, kind, opened: true }
}
}
@@ -2,6 +2,7 @@ import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resum
import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry'
import type { TerminalRevealIdentity } from '../../shared/terminal-reveal-identity'
import type { TuiAgent } from '../../shared/tui-agent'
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows'
import type {
WorktreeBaseStatusEvent,
@@ -97,18 +98,24 @@ export type RuntimeNotifier = {
focusEditorTab?(tabId: string, worktreeId: string): void
closeSessionTab?(tabId: string, worktreeId: string): void | Promise<void>
moveSessionTab?(worktreeId: string, move: RuntimeMobileSessionTabMove): void
/**
* Acts only on the host's own window: 'host'/'all' move it, 'caller'/'clients' open without moving
* it, absent keeps the original switch. Paired clients are never navigated (intended; 'all' == 'host').
*/
openFile?(
worktreeId: string,
filePath: string,
relativePath: string,
runtimeEnvironmentId?: string | null
runtimeEnvironmentId?: string | null,
navigation?: RuntimeNavigationTarget
): void
openDiff?(
worktreeId: string,
filePath: string,
relativePath: string,
staged: boolean,
runtimeEnvironmentId?: string | null
runtimeEnvironmentId?: string | null,
navigation?: RuntimeNavigationTarget
): void
readMobileMarkdownTab?(worktreeId: string, tabId: string): Promise<RuntimeMarkdownReadTabResult>
saveMobileMarkdownTab?(
@@ -635,7 +635,7 @@ describe('OrcaRuntimeRpcServer', () => {
expect(mocks.abortRuntimeGitMerge).toHaveBeenCalledWith('id:wt-1')
expect(mocks.abortRuntimeGitRebase).toHaveBeenCalledWith('id:wt-1')
expect(mocks.bulkUnstageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['c.ts'])
expect(mocks.openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', true)
expect(mocks.openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', true, undefined)
// A mobile WebSocket client is transport-capped; a local caller gets undefined here.
expect(mocks.getRuntimeGitDiff).toHaveBeenCalledWith(
'id:wt-1',
@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest'
import type { BrowserWindow } from 'electron'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { RuntimeNotifier } from '../runtime/runtime-notifier-contract'
vi.mock('electron', () => ({ ipcMain: { on: vi.fn(), removeListener: vi.fn() } }))
vi.mock('../ipc/worktree-change-invalidators', () => ({ runWorktreeChangeInvalidators: vi.fn() }))
vi.mock('./mobile-markdown-request-relay', () => ({ requestMobileMarkdownFromRenderer: vi.fn() }))
vi.mock('./renderer-document-navigation', () => ({ registerRendererDocumentNavigation: vi.fn() }))
vi.mock('./session-tab-close-request-relay', () => ({
requestSessionTabCloseFromRenderer: vi.fn()
}))
vi.mock('./terminal-tab-close-request-relay', () => ({
requestTerminalTabCloseFromRenderer: vi.fn()
}))
import { registerRuntimeWindowLifecycle } from './runtime-window-lifecycle'
function attachNotifier(): { notifier: RuntimeNotifier; send: ReturnType<typeof vi.fn> } {
const send = vi.fn()
const attached: { notifier: RuntimeNotifier | null } = { notifier: null }
const mainWindow = {
id: 1,
isDestroyed: () => false,
on: vi.fn(),
webContents: { isDestroyed: () => false, send, on: vi.fn() }
}
const runtime = {
attachWindow: vi.fn(),
markGraphReloadFailed: vi.fn(),
setNotifier: (next: RuntimeNotifier | null) => {
attached.notifier = next
}
}
registerRuntimeWindowLifecycle(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registration only reads id, isDestroyed, on and webContents.
mainWindow as unknown as BrowserWindow,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registration only calls the stubbed runtime members.
runtime as unknown as OrcaRuntimeService
)
if (!attached.notifier) {
throw new Error('runtime notifier was not attached')
}
return { notifier: attached.notifier, send }
}
describe('runtime window file-open notifications', () => {
it('omits navigation when the caller sent none (legacy switch)', () => {
const { notifier, send } = attachNotifier()
notifier.openFile?.('wt-1', '/repo/a.ts', 'a.ts', undefined)
notifier.openDiff?.('wt-1', '/repo/a.ts', 'a.ts', true, undefined)
expect(send).toHaveBeenCalledWith('ui:openFileFromMobile', {
worktreeId: 'wt-1',
filePath: '/repo/a.ts',
relativePath: 'a.ts',
runtimeEnvironmentId: undefined
})
expect(send).toHaveBeenCalledWith('ui:openDiffFromMobile', {
worktreeId: 'wt-1',
filePath: '/repo/a.ts',
relativePath: 'a.ts',
staged: true,
runtimeEnvironmentId: undefined
})
for (const [, payload] of send.mock.calls) {
expect(payload).not.toHaveProperty('navigation')
}
})
it('forwards an explicit navigation target to the renderer', () => {
const { notifier, send } = attachNotifier()
notifier.openFile?.('wt-1', '/repo/a.ts', 'a.ts', undefined, 'all')
notifier.openDiff?.('wt-1', '/repo/a.ts', 'a.ts', false, undefined, 'caller')
expect(send).toHaveBeenCalledWith(
'ui:openFileFromMobile',
expect.objectContaining({ navigation: 'all' })
)
expect(send).toHaveBeenCalledWith(
'ui:openDiffFromMobile',
expect.objectContaining({ navigation: 'caller' })
)
})
})
+6 -4
View File
@@ -169,20 +169,22 @@ export function registerRuntimeWindowLifecycle(
requestSessionTabCloseFromRenderer(mainWindow, tabId, worktreeId),
moveSessionTab: (worktreeId: string, move: RuntimeMobileSessionTabMove) =>
send('ui:moveSessionTab', { worktreeId, ...move }),
openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId?) =>
openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId?, navigation?) =>
send('ui:openFileFromMobile', {
worktreeId,
filePath,
relativePath,
runtimeEnvironmentId
runtimeEnvironmentId,
...(navigation ? { navigation } : {})
}),
openDiff: (worktreeId, filePath, relativePath, staged, runtimeEnvironmentId?) =>
openDiff: (worktreeId, filePath, relativePath, staged, runtimeEnvironmentId?, navigation?) =>
send('ui:openDiffFromMobile', {
worktreeId,
filePath,
relativePath,
staged,
runtimeEnvironmentId
runtimeEnvironmentId,
...(navigation ? { navigation } : {})
}),
readMobileMarkdownTab: (worktreeId, tabId) =>
requestMobileMarkdownFromRenderer(mainWindow, {
@@ -10,6 +10,7 @@ import {
type RichMarkdownContextMenuTableTarget
} from '../../shared/rich-markdown-context-menu'
import type { NativeFileDropPayload } from '../../shared/native-file-drop'
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target'
import type { ClipboardImageThumbnail } from '../../shared/clipboard-image'
import type { ReadClipboardTextOptions } from '../../shared/clipboard-text'
@@ -24,6 +25,7 @@ export const uiClipboardAndWindowControlsApi = {
relativePath: string
staged: boolean
runtimeEnvironmentId?: string
navigation?: RuntimeNavigationTarget
}) => void
): (() => void) => {
const listener = (
@@ -34,6 +36,7 @@ export const uiClipboardAndWindowControlsApi = {
relativePath: string
staged: boolean
runtimeEnvironmentId?: string
navigation?: RuntimeNavigationTarget
}
) => callback(data)
ipcRenderer.on('ui:openDiffFromMobile', listener)
@@ -6,6 +6,7 @@ import type {
SleepingAgentLaunchConfig
} from '../../shared/agent-session-resume'
import type { TuiAgent } from '../../shared/tui-agent'
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
import type {
RuntimeMobileSessionTabMove,
RuntimeTerminalCreateRequestPayload,
@@ -203,6 +204,7 @@ export const uiTerminalAndSessionTabsApi = {
filePath: string
relativePath: string
runtimeEnvironmentId?: string
navigation?: RuntimeNavigationTarget
}) => void
): (() => void) => {
const listener = (
@@ -212,6 +214,7 @@ export const uiTerminalAndSessionTabsApi = {
filePath: string
relativePath: string
runtimeEnvironmentId?: string
navigation?: RuntimeNavigationTarget
}
) => callback(data)
ipcRenderer.on('ui:openFileFromMobile', listener)
+3
View File
@@ -1,6 +1,7 @@
import type { MarkdownDocument } from '../../shared/filesystem-entry-types'
import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
import type { TuiAgent } from '../../shared/tui-agent'
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target'
import type {
WorktreeDefaultTabsLaunch,
@@ -224,6 +225,7 @@ export type UiCommandEventApi = {
filePath: string
relativePath: string
runtimeEnvironmentId?: string
navigation?: RuntimeNavigationTarget
}) => void
) => () => void
onOpenDiffFromMobile: (
@@ -233,6 +235,7 @@ export type UiCommandEventApi = {
relativePath: string
staged: boolean
runtimeEnvironmentId?: string
navigation?: RuntimeNavigationTarget
}) => void
) => () => void
onMobileMarkdownRequest: (callback: (request: RuntimeMobileMarkdownRequest) => void) => () => void
@@ -0,0 +1,404 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeNavigationTarget } from '../../../../shared/runtime-navigation'
import { createTestStore, makeWorktree, seedStore } from '../../store/slices/store-test-helpers'
import { createStoreSessionMockApi } from '../../store/slices/store-session-test-harness'
import { buildMobileSessionTabSnapshots } from '@/runtime/sync-runtime-graph/mobile-session-snapshots'
const { storeRef } = vi.hoisted(() => {
const ref: { current: ReturnType<typeof createTestStore> | null } = { current: null }
return { storeRef: ref }
})
function testStore(): ReturnType<typeof createTestStore> {
if (!storeRef.current) {
throw new Error('test store not created')
}
return storeRef.current
}
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
vi.mock('../../store', () => ({
useAppStore: {
getState: () => testStore().getState(),
setState: (...args: Parameters<ReturnType<typeof createTestStore>['setState']>) =>
testStore().setState(...args)
}
}))
vi.mock('@/components/terminal-pane/closed-terminal-leaf-notice', () => ({
applyClosedTerminalLeafNotice: vi.fn()
}))
vi.mock('@/components/terminal/terminal-tab-actions', () => ({ closeTerminalTab: vi.fn() }))
vi.mock('@/components/sidebar/sleep-worktree-flow', () => ({ runSleepWorktree: vi.fn() }))
vi.mock('@/lib/workspace-session', () => ({ buildWorkspaceSessionPayload: vi.fn() }))
vi.mock('@/lib/workspace-session-host-persistence', () => ({
persistWorkspaceSessionByHost: vi.fn()
}))
import { registerMobileAndTerminalCloseIpcBridge } from './mobile-terminal-close-ipc-bridge'
import { handleSwitchRecentTab } from '../ipc-tab-switch'
type OpenFilePayload = {
worktreeId: string
filePath: string
relativePath: string
runtimeEnvironmentId?: string
navigation?: RuntimeNavigationTarget
}
type OpenDiffPayload = OpenFilePayload & { staged: boolean }
type TestStore = ReturnType<typeof createTestStore>
const VIEWED = 'repo1::/repo1/viewed'
const BACKGROUND = 'repo1::/repo1/background'
const VIEWED_FILE = '/repo1/viewed/notes.md'
const APP_TS = { filePath: '/repo1/background/src/app.ts', relativePath: 'src/app.ts' }
const VIEWED_APP_TS = { filePath: '/repo1/viewed/src/app.ts', relativePath: 'src/app.ts' }
function setup(viewedSurface: 'editor' | 'terminal'): {
openFile: (payload: OpenFilePayload) => void
openDiff: (payload: OpenDiffPayload) => void
store: TestStore
} {
const mockApi = createStoreSessionMockApi()
const listeners: {
openFile?: (payload: OpenFilePayload) => void
openDiff?: (payload: OpenDiffPayload) => void
} = {}
vi.stubGlobal('window', {
api: {
...mockApi,
ui: {
onOpenFileFromMobile: (cb: (payload: OpenFilePayload) => void) => {
listeners.openFile = cb
return () => {}
},
onOpenDiffFromMobile: (cb: (payload: OpenDiffPayload) => void) => {
listeners.openDiff = cb
return () => {}
},
onCloseTerminal: () => () => {},
onSleepWorktree: () => () => {},
onResumeSleepingAgents: () => () => {}
}
}
})
const store = createTestStore()
storeRef.current = store
seedStore(store, {
worktreesByRepo: {
repo1: [
makeWorktree({ id: VIEWED, repoId: 'repo1', path: '/repo1/viewed' }),
makeWorktree({ id: BACKGROUND, repoId: 'repo1', path: '/repo1/background' })
]
}
})
store.getState().setActiveWorktree(VIEWED)
store.getState().openFile({
filePath: VIEWED_FILE,
relativePath: 'notes.md',
worktreeId: VIEWED,
language: 'markdown',
runtimeEnvironmentId: null,
mode: 'edit'
})
if (viewedSurface === 'terminal') {
store.getState().createTab(VIEWED)
store.getState().setActiveTabType('terminal', VIEWED)
}
store.setState({ activeView: 'terminal', pendingRevealWorktree: null })
registerMobileAndTerminalCloseIpcBridge([], vi.fn())
const { openFile, openDiff } = listeners
if (!openFile || !openDiff) {
throw new Error('file-open listeners were not registered')
}
return { openFile, openDiff, store }
}
/** Everything the user can see or type into on the desktop. */
function screenState(store: TestStore): unknown {
const s = store.getState()
return {
activeWorktreeId: s.activeWorktreeId,
activeView: s.activeView,
activeTabType: s.activeTabType,
activeFileId: s.activeFileId,
activeTabId: s.activeTabId,
viewedActiveFile: s.activeFileIdByWorktree[VIEWED],
viewedActiveTabType: s.activeTabTypeByWorktree[VIEWED],
viewedActiveGroup: s.activeGroupIdByWorktree[VIEWED],
viewedGroups: s.groupsByWorktree[VIEWED]?.map((group) => ({
id: group.id,
activeTabId: group.activeTabId
})),
pendingEditorFocusRequest: s.pendingEditorFocusRequest,
pendingRevealWorktree: s.pendingRevealWorktree,
backgroundVisitedAt: s.lastVisitedAtByWorktreeId[BACKGROUND]
}
}
function tabEntityIds(store: TestStore, worktreeId: string): string[] {
return (store.getState().unifiedTabsByWorktree[worktreeId] ?? []).map((tab) => tab.entityId)
}
function activeEditorEntityId(store: TestStore, worktreeId: string): string | undefined {
const state = store.getState()
const group = state.groupsByWorktree[worktreeId]?.find(
(candidate) => candidate.id === state.activeGroupIdByWorktree[worktreeId]
)
return state.unifiedTabsByWorktree[worktreeId]?.find((tab) => tab.id === group?.activeTabId)
?.entityId
}
describe('runtime file opens on the host desktop', () => {
beforeEach(() => {
vi.unstubAllGlobals()
})
it('CLI caller open adds the tab to the viewed worktree without leaving the focused terminal', () => {
const { openFile, store } = setup('terminal')
const before = screenState(store)
expect(store.getState().activeTabType).toBe('terminal')
openFile({ worktreeId: VIEWED, ...VIEWED_APP_TS, navigation: 'caller' })
expect(screenState(store)).toEqual(before)
expect(tabEntityIds(store, VIEWED)).toContain(VIEWED_APP_TS.filePath)
})
it('CLI caller diff is added to the viewed worktree without replacing the visible editor', () => {
const { openDiff, store } = setup('editor')
const before = screenState(store)
expect(store.getState().activeFileId).toBe(VIEWED_FILE)
openDiff({ worktreeId: VIEWED, ...VIEWED_APP_TS, staged: false, navigation: 'caller' })
expect(screenState(store)).toEqual(before)
expect(
store.getState().openFiles.some((file) => file.mode === 'diff' && file.worktreeId === VIEWED)
).toBe(true)
})
it('CLI caller open in a background worktree leaves the viewed editor alone', () => {
const { openFile, store } = setup('editor')
const before = screenState(store)
openFile({ worktreeId: BACKGROUND, ...APP_TS, navigation: 'caller' })
expect(screenState(store)).toEqual(before)
// Why: the tab is that worktree's selection, so it is what the user sees on going there.
expect(activeEditorEntityId(store, BACKGROUND)).toBe(APP_TS.filePath)
expect(store.getState().activeFileIdByWorktree[BACKGROUND]).toBe(APP_TS.filePath)
store.getState().setActiveWorktree(BACKGROUND)
expect(store.getState().activeFileId).toBe(APP_TS.filePath)
expect(store.getState().activeTabType).toBe('editor')
})
it('CLI caller diff in a background worktree leaves the viewed editor alone', () => {
const { openDiff, store } = setup('editor')
const before = screenState(store)
openDiff({ worktreeId: BACKGROUND, ...APP_TS, staged: true, navigation: 'caller' })
expect(screenState(store)).toEqual(before)
const opened = store
.getState()
.openFiles.find((file) => file.mode === 'diff' && file.worktreeId === BACKGROUND)
expect(opened?.diffSource).toBe('staged')
expect(activeEditorEntityId(store, BACKGROUND)).toBe(opened?.id)
})
it('CLI caller reopen of an already-open file keeps the focused terminal', () => {
const { openFile, store } = setup('terminal')
const before = screenState(store)
const tabCount = tabEntityIds(store, VIEWED).length
openFile({
worktreeId: VIEWED,
filePath: VIEWED_FILE,
relativePath: 'notes.md',
navigation: 'caller'
})
openFile({
worktreeId: VIEWED,
filePath: VIEWED_FILE,
relativePath: 'notes.md',
navigation: 'caller'
})
expect(screenState(store)).toEqual(before)
expect(tabEntityIds(store, VIEWED)).toHaveLength(tabCount)
})
it('CLI caller reopen of an already-open diff keeps the focused terminal', () => {
const { openDiff, store } = setup('terminal')
const before = screenState(store)
openDiff({ worktreeId: VIEWED, ...VIEWED_APP_TS, staged: false, navigation: 'caller' })
const tabCount = tabEntityIds(store, VIEWED).length
openDiff({ worktreeId: VIEWED, ...VIEWED_APP_TS, staged: false, navigation: 'caller' })
expect(screenState(store)).toEqual(before)
expect(tabEntityIds(store, VIEWED)).toHaveLength(tabCount)
})
it('selects a background CLI tab without stamping a visit, keeping its tab history', () => {
const { openFile, store } = setup('editor')
const terminal = store.getState().createTab(BACKGROUND)
const tabId = (entityId: string): string | undefined =>
store.getState().unifiedTabsByWorktree[BACKGROUND]?.find((tab) => tab.entityId === entityId)
?.id
const focusTimes = (): [string, number | undefined][] =>
(store.getState().unifiedTabsByWorktree[BACKGROUND] ?? []).map((tab) => [
tab.entityId,
tab.lastFocusedAt
])
const history = (): string[] | undefined =>
store.getState().groupsByWorktree[BACKGROUND]?.[0]?.recentTabIds
const timesBefore = focusTimes()
openFile({ worktreeId: BACKGROUND, ...APP_TS, navigation: 'caller' })
expect(activeEditorEntityId(store, BACKGROUND)).toBe(APP_TS.filePath)
// Why: the jump palette's recent rows sort by lastFocusedAt; the user never looked at this tab.
expect(focusTimes()).toEqual([...timesBefore, [APP_TS.filePath, undefined]])
// Why: the group history must still end on the active tab, or Ctrl+Tab has nowhere to go back to.
expect(history()?.at(-1)).toBe(tabId(APP_TS.filePath))
store.getState().activateTab(tabId(terminal.id) ?? '')
const timesBeforeReopen = focusTimes()
openFile({ worktreeId: BACKGROUND, ...APP_TS, navigation: 'caller' })
expect(activeEditorEntityId(store, BACKGROUND)).toBe(APP_TS.filePath)
expect(focusTimes()).toEqual(timesBeforeReopen)
expect(history()?.at(-1)).toBe(tabId(APP_TS.filePath))
})
it.each([
['a new tab', false],
['a reopened tab already in the history', true]
] as const)('Ctrl+Tab works after a background CLI open of %s', (_label, reopen) => {
const { openFile, store } = setup('editor')
store.getState().setActiveWorktree(BACKGROUND)
const terminal = store.getState().createTab(BACKGROUND)
store.getState().setActiveTabType('terminal', BACKGROUND)
store.getState().setActiveWorktree(VIEWED)
const terminalTabId = store
.getState()
.unifiedTabsByWorktree[BACKGROUND]?.find((tab) => tab.entityId === terminal.id)?.id
if (reopen) {
openFile({ worktreeId: BACKGROUND, ...APP_TS, navigation: 'caller' })
// Why: the user went back to the terminal, so the file is in the history but not last.
store.getState().activateTab(terminalTabId ?? '')
}
openFile({ worktreeId: BACKGROUND, ...APP_TS, navigation: 'caller' })
store.getState().setActiveWorktree(BACKGROUND)
store.getState().setActiveTabType('editor', BACKGROUND)
expect(activeEditorEntityId(store, BACKGROUND)).toBe(APP_TS.filePath)
expect(handleSwitchRecentTab()).toBe(true)
expect(activeEditorEntityId(store, BACKGROUND)).toBe(terminal.id)
})
it('switches the desktop for a phone open (no navigation field), as before', () => {
const { openFile, store } = setup('terminal')
openFile({ worktreeId: BACKGROUND, ...APP_TS })
const state = store.getState()
expect(state.activeWorktreeId).toBe(BACKGROUND)
expect(state.activeView).toBe('terminal')
expect(state.activeFileId).toBe(APP_TS.filePath)
expect(state.activeTabType).toBe('editor')
expect(activeEditorEntityId(store, BACKGROUND)).toBe(APP_TS.filePath)
expect(state.pendingRevealWorktree?.worktreeId).toBe(BACKGROUND)
expect(state.lastVisitedAtByWorktreeId[BACKGROUND]).toBeDefined()
})
it('selects a phone-opened diff in the viewed worktree so "Open in session" lands on it', () => {
const { openDiff, store } = setup('terminal')
openDiff({ worktreeId: VIEWED, ...VIEWED_APP_TS, staged: false })
const state = store.getState()
const diff = state.openFiles.find((file) => file.mode === 'diff' && file.worktreeId === VIEWED)
expect(state.activeWorktreeId).toBe(VIEWED)
expect(state.activeTabType).toBe('editor')
expect(state.activeFileId).toBe(diff?.id)
expect(activeEditorEntityId(store, VIEWED)).toBe(diff?.id)
const snapshot = buildMobileSessionTabSnapshots(state, false).find(
(candidate) => candidate.worktree === VIEWED
)
const activeTab = snapshot?.tabs.find((tab) => tab.id === snapshot.activeTabId)
expect(activeTab && 'relativePath' in activeTab ? activeTab.relativePath : null).toBe(
VIEWED_APP_TS.relativePath
)
})
it('publishes a CLI caller tab to the phone tab list without moving the desktop', () => {
const { openFile, store } = setup('terminal')
const before = screenState(store)
openFile({ worktreeId: VIEWED, ...VIEWED_APP_TS, navigation: 'caller' })
expect(screenState(store)).toEqual(before)
const snapshot = buildMobileSessionTabSnapshots(store.getState(), false).find(
(candidate) => candidate.worktree === VIEWED
)
expect(
snapshot?.tabs.some(
(tab) => 'relativePath' in tab && tab.relativePath === VIEWED_APP_TS.relativePath
)
).toBe(true)
})
it.each(['caller', 'clients'] as const)(
'keeps the desktop still when navigation %s does not target the host',
(navigation) => {
const { openFile, store } = setup('terminal')
const before = screenState(store)
openFile({ worktreeId: VIEWED, ...VIEWED_APP_TS, navigation })
openFile({ worktreeId: BACKGROUND, ...APP_TS, navigation })
expect(screenState(store)).toEqual(before)
}
)
it.each(['all', 'host'] as const)(
'brings the user to the file with navigation %s',
(navigation) => {
const { openFile, store } = setup('terminal')
openFile({ worktreeId: BACKGROUND, ...APP_TS, navigation })
const state = store.getState()
expect(state.activeWorktreeId).toBe(BACKGROUND)
expect(state.activeView).toBe('terminal')
expect(state.activeFileId).toBe(APP_TS.filePath)
expect(state.activeTabType).toBe('editor')
expect(state.pendingRevealWorktree?.worktreeId).toBe(BACKGROUND)
expect(state.lastVisitedAtByWorktreeId[BACKGROUND]).toBeDefined()
}
)
it('selects the tab over a focused terminal in the viewed worktree with navigation all', () => {
const { openFile, store } = setup('terminal')
openFile({ worktreeId: VIEWED, ...VIEWED_APP_TS, navigation: 'all' })
expect(store.getState().activeTabType).toBe('editor')
expect(store.getState().activeFileId).toBe(VIEWED_APP_TS.filePath)
expect(activeEditorEntityId(store, VIEWED)).toBe(VIEWED_APP_TS.filePath)
})
it('brings the user to a diff with navigation all', () => {
const { openDiff, store } = setup('editor')
openDiff({ worktreeId: BACKGROUND, ...APP_TS, staged: false, navigation: 'all' })
const state = store.getState()
expect(state.activeWorktreeId).toBe(BACKGROUND)
expect(state.openFiles.find((file) => file.id === state.activeFileId)?.mode).toBe('diff')
expect(state.pendingRevealWorktree?.worktreeId).toBe(BACKGROUND)
})
})
@@ -5,6 +5,33 @@ import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow'
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
import { persistWorkspaceSessionByHost } from '@/lib/workspace-session-host-persistence'
import { useAppStore } from '../../store'
import type { AppState } from '../../store/types'
import type { EditorTabSelection } from '../../store/slices/editor/types/open-file'
import {
navigationTargetsHost,
type RuntimeNavigationTarget
} from '../../../../shared/runtime-navigation'
// Why: a caller that names a non-host target (CLI without --focus) must not change anything on screen;
// the tab is selected only inside a worktree the user is not viewing, without counting as a visit.
// No target (phones, older CLIs) keeps the original switch, which the phone's "Open in session" relies on.
function openRuntimeEditorTab(
worktreeId: string,
navigation: RuntimeNavigationTarget | undefined,
open: (store: AppState, selection: EditorTabSelection) => void
): void {
const store = useAppStore.getState()
if (navigation !== undefined && !navigationTargetsHost(navigation)) {
open(store, worktreeId === store.activeWorktreeId ? 'none' : 'background')
return
}
store.setActiveWorktree(worktreeId)
store.markWorktreeVisited(worktreeId)
store.setActiveView('terminal')
open(store, 'focus')
store.setActiveTabType('editor', worktreeId)
store.revealWorktreeInSidebar(worktreeId)
}
export function registerMobileAndTerminalCloseIpcBridge(
unsubs: (() => void)[],
@@ -12,41 +39,36 @@ export function registerMobileAndTerminalCloseIpcBridge(
): void {
unsubs.push(
window.api.ui.onOpenFileFromMobile(
({ worktreeId, filePath, relativePath, runtimeEnvironmentId }) => {
const store = useAppStore.getState()
({ worktreeId, filePath, relativePath, runtimeEnvironmentId, navigation }) => {
const basename = relativePath.split(/[\\/]/).pop() || relativePath
store.setActiveWorktree(worktreeId)
store.markWorktreeVisited(worktreeId)
store.setActiveView('terminal')
// Why: renderer owns tab creation so grouped order and markdown bridges share the desktop File Explorer's store path.
store.openFile({
filePath,
relativePath,
worktreeId,
language: detectLanguage(basename),
runtimeEnvironmentId,
mode: 'edit'
})
store.setActiveTabType('editor', worktreeId)
store.revealWorktreeInSidebar(worktreeId)
openRuntimeEditorTab(worktreeId, navigation, (store, selection) =>
// Why: renderer owns tab creation so grouped order and markdown bridges share the desktop File Explorer's store path.
store.openFile(
{
filePath,
relativePath,
worktreeId,
language: detectLanguage(basename),
runtimeEnvironmentId,
mode: 'edit'
},
{ selection }
)
)
}
)
)
unsubs.push(
window.api.ui.onOpenDiffFromMobile(
({ worktreeId, filePath, relativePath, staged, runtimeEnvironmentId }) => {
const store = useAppStore.getState()
const language = detectLanguage(relativePath)
store.setActiveWorktree(worktreeId)
store.markWorktreeVisited(worktreeId)
store.setActiveView('terminal')
// Why: mobile renders diffs from metadata; the editor-local Changes shortcut would send plain markdown back to mobile.
store.openDiff(worktreeId, filePath, relativePath, language, staged, {
runtimeEnvironmentId
})
store.setActiveTabType('editor', worktreeId)
store.revealWorktreeInSidebar(worktreeId)
({ worktreeId, filePath, relativePath, staged, runtimeEnvironmentId, navigation }) => {
openRuntimeEditorTab(worktreeId, navigation, (store, selection) =>
// Why: mobile renders diffs from metadata; the editor-local Changes shortcut would send plain markdown back to mobile.
store.openDiff(worktreeId, filePath, relativePath, detectLanguage(relativePath), staged, {
runtimeEnvironmentId,
selection
})
)
}
)
)
@@ -265,6 +265,8 @@ describe('createEditorSlice recently closed editor tabs', () => {
const liveWt1Id = openShared('wt-1')
expect(liveWt1Id).toBe(ownedEditorFileId(sharedPath, 'wt-1', null))
store.getState().closeFile(wt2Id)
// Why: closing wt-1's last tab above left the landing state; reopen runs for the on-screen worktree.
store.setState({ activeWorktreeId: 'wt-1' })
expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true)
@@ -36,7 +36,8 @@ export function createOpenFileAction(
editorItemLabel,
editorItemContentType,
scratch.editorItemIsPreview,
scratch.editorItemTargetGroupId
scratch.editorItemTargetGroupId,
options?.selection
)
if (options?.focusEditor) {
set({
@@ -5,6 +5,7 @@ import { getRecentlyClosedTabPosition, pushRecentlyClosedTabKind } from '../../r
import type { AppState } from '../../../types'
import {
type ClosedEditorTabSnapshot,
type EditorTabSelection,
MAX_RECENT_CLOSED_EDITOR_TABS,
type OpenFile
} from '../types/open-file'
@@ -45,6 +46,7 @@ export function applyOpenFileToState(
forceContentReload?: boolean
focusEditor?: boolean
reopenId?: string
selection?: EditorTabSelection
}
| undefined,
scratch: OpenFileApplyScratch
@@ -99,7 +101,8 @@ export function applyOpenFileToState(
const targetGroupId =
resolveEditorOpenTargetGroupId(s, worktreeId, options?.targetGroupId) ?? undefined
scratch.editorItemTargetGroupId = targetGroupId
const activeResult = buildEditorActiveResult(s, worktreeId, id)
const activeResult =
options?.selection === 'none' ? {} : buildEditorActiveResult(s, worktreeId, id)
if (existing) {
// If opening as non-preview, also pin the existing tab
const updatedPreview = isPreview ? existing.isPreview : false
@@ -3,7 +3,10 @@ import type { EditorSlice } from '../types/editor-slice'
import type { DiffSource, OpenFile } from '../types/open-file'
import { buildDiffEditorFileId, withDiffContentReloadRequest } from '../file-ids/editor-file-ids'
import { resolveDiffRuntimeEnvironmentId } from '../git/diff-runtime-owner'
import { resolveEditorOpenTargetGroupId } from '../tabs/editor-open-target-group'
import {
buildEditorActiveResult,
resolveEditorOpenTargetGroupId
} from '../tabs/editor-open-target-group'
import { resolveEditorPreviewIntent } from '../tabs/editor-preview-tab-setting'
import {
getReplaceablePreviewFileId,
@@ -18,6 +21,7 @@ export function createOpenUnstagedDiff(
return {
openDiff: (worktreeId, filePath, relativePath, language, staged, options) => {
const isPreview = resolveEditorPreviewIntent(get(), options?.preview)
const selection = options?.selection
let editorItemTargetGroupId = options?.targetGroupId
let editorItemFileId = ''
set((s) => {
@@ -32,6 +36,7 @@ export function createOpenUnstagedDiff(
const targetGroupId =
resolveEditorOpenTargetGroupId(s, worktreeId, options?.targetGroupId) ?? undefined
editorItemTargetGroupId = targetGroupId
const activeResult = selection === 'none' ? {} : buildEditorActiveResult(s, worktreeId, id)
const existing = s.openFiles.find((f) => f.id === id)
if (existing) {
const updatedPreview = isPreview ? existing.isPreview : false
@@ -47,10 +52,7 @@ export function createOpenUnstagedDiff(
})
return {
openFiles: s.openFiles.map((f) => (f.id === id ? reopenedDiff : f)),
activeFileId: id,
activeTabType: 'editor',
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
...activeResult
}
}
const newFile: OpenFile = {
@@ -79,19 +81,13 @@ export function createOpenUnstagedDiff(
index === replaceablePreviewIndex ? newFile : file
),
...removeEditorStateForReplacedPreview(s, s.openFiles[replaceablePreviewIndex], id),
activeFileId: id,
activeTabType: 'editor',
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
...activeResult
}
}
}
return {
openFiles: [...s.openFiles, newFile],
activeFileId: id,
activeTabType: 'editor',
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
...activeResult
}
})
void openWorkspaceEditorItem(
@@ -101,7 +97,8 @@ export function createOpenUnstagedDiff(
relativePath,
'diff',
isPreview,
editorItemTargetGroupId
editorItemTargetGroupId,
selection
)
}
}
@@ -1,7 +1,6 @@
import type { AppState } from '../../../types'
import type { Tab, TabGroup, WorkspaceVisibleTabType } from '../../../../../../shared/tab-types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../../shared/constants'
import type { EditorSlice } from '../types/editor-slice'
import { isEditorTabContentType } from './editor-tab-content-type'
export function getGroupActiveTab(group: TabGroup, tabsById: Map<string, Tab>): Tab | null {
@@ -81,7 +80,7 @@ export function resolveEditorOpenTargetGroupId(
}
export function buildEditorActiveResult(
state: Pick<EditorSlice, 'activeFileIdByWorktree' | 'activeTabTypeByWorktree'>,
state: Pick<AppState, 'activeFileIdByWorktree' | 'activeTabTypeByWorktree' | 'activeWorktreeId'>,
worktreeId: string,
fileId: string
): {
@@ -91,8 +90,8 @@ export function buildEditorActiveResult(
activeTabTypeByWorktree: Record<string, WorkspaceVisibleTabType>
} {
return {
// Why: floating markdown tabs must not become the worktree's active editor, so update only the per-worktree maps.
...(worktreeId === FLOATING_TERMINAL_WORKTREE_ID
// Why: only the on-screen worktree owns the main editor; a background or floating open updates only the per-worktree maps.
...(worktreeId === FLOATING_TERMINAL_WORKTREE_ID || worktreeId !== state.activeWorktreeId
? {}
: { activeFileId: fileId, activeTabType: 'editor' as const }),
activeFileIdByWorktree: { ...state.activeFileIdByWorktree, [worktreeId]: fileId },
@@ -1,6 +1,6 @@
import type { AppState } from '../../../types'
import type { EditorSlice } from '../types/editor-slice'
import type { OpenFile } from '../types/open-file'
import type { EditorTabSelection, OpenFile } from '../types/open-file'
import { resolveEditorOpenTargetGroupId } from './editor-open-target-group'
import { areEditorPreviewTabsEnabled } from './editor-preview-tab-setting'
import { isEditorTabContentType } from './editor-tab-content-type'
@@ -12,7 +12,8 @@ export function openWorkspaceEditorItem(
label: string,
contentType: 'editor' | 'diff' | 'conflict-review' | 'check-details',
isPreview?: boolean,
targetGroupId?: string
targetGroupId?: string,
selection: EditorTabSelection = 'focus'
): string {
const resolvedGroupId = resolveEditorOpenTargetGroupId(state, worktreeId, targetGroupId)
if (resolvedGroupId) {
@@ -23,8 +24,13 @@ export function openWorkspaceEditorItem(
contentType
)
if (existing) {
// Why: sidebar preview reopens focus the tab without promoting it; explicit activation still promotes previews by default.
state.activateTab?.(existing.id, { preservePreview: isPreview })
if (selection !== 'none') {
// Why: sidebar preview reopens focus the tab without promoting it; explicit activation still promotes previews by default.
state.activateTab?.(existing.id, {
preservePreview: isPreview,
...(selection === 'background' ? { recordFocus: false } : {})
})
}
return existing.id
}
}
@@ -32,7 +38,9 @@ export function openWorkspaceEditorItem(
entityId: fileId,
label,
isPreview,
...(resolvedGroupId ? { targetGroupId: resolvedGroupId } : {})
...(resolvedGroupId ? { targetGroupId: resolvedGroupId } : {}),
...(selection === 'none' ? { activate: false } : {}),
...(selection === 'background' ? { recordFocus: false } : {})
})
return created?.id ?? fileId
}
@@ -18,6 +18,7 @@ import type {
ConflictReviewEntry,
ConflictReviewState,
EditorOpenTargetOptions,
EditorTabSelection,
OpenFile
} from './open-file'
import type { OpenFilePathRekey, RekeyOpenFilesResult } from './open-file-path-rekey'
@@ -44,6 +45,7 @@ export type EditorFilesSlice = {
forceContentReload?: boolean
focusEditor?: boolean
reopenId?: string
selection?: EditorTabSelection
}
) => string
openNewMarkdownInActiveWorkspace: (groupId: string) => Promise<void>
@@ -101,7 +103,7 @@ export type EditorFilesSlice = {
relativePath: string,
language: string,
staged: boolean,
options?: EditorOpenTargetOptions
options?: EditorOpenTargetOptions & { selection?: EditorTabSelection }
) => void
openBranchDiff: (
worktreeId: string,
@@ -166,6 +166,12 @@ export type ClosedEditorTabSnapshot = Omit<
export const MAX_RECENT_CLOSED_EDITOR_TABS = 10
/**
* How an editor open selects its tab. 'focus' (default) selects it as a user action; 'background'
* selects it inside its own worktree without stamping its focus time; 'none' only adds it.
*/
export type EditorTabSelection = 'focus' | 'background' | 'none'
export type EditorOpenTargetOptions = {
targetGroupId?: string
preview?: boolean
@@ -401,6 +401,8 @@ describe('New Markdown — rename flow store operations', () => {
expect(store.getState().activeFileId).toBe('/repo/untitled.md')
store.getState().closeFile('/repo/untitled.md')
// Why: closing the only tab left the landing state; the renamed file opens back on the on-screen worktree.
store.setState({ activeWorktreeId: 'wt-1' })
store.getState().openFile({
filePath: '/repo/notes.md',
relativePath: 'notes.md',
@@ -66,6 +66,7 @@ export function createTabsCreateActions(
}
const shouldActivate = init?.activate ?? true
const recordsFocus = shouldActivate && init?.recordFocus !== false
const createdAt = Date.now()
const executionHostId =
init?.executionHostId ?? getActiveExecutionHostIdForWorktree(state, worktreeId)
@@ -100,7 +101,7 @@ export function createTabsCreateActions(
sortOrder: insertedIndex,
createdAt,
// Why: creating an active tab is a focus event; Cmd+J recency reads lastFocusedAt.
...(shouldActivate ? { lastFocusedAt: createdAt } : {}),
...(recordsFocus ? { lastFocusedAt: createdAt } : {}),
isPreview: init?.isPreview,
isPinned: init?.isPinned
}
@@ -77,7 +77,7 @@ export function createTabsFocusActions(
? {
...item,
isPreview: opts?.preservePreview ? item.isPreview : false,
lastFocusedAt: Date.now()
...(opts?.recordFocus === false ? {} : { lastFocusedAt: Date.now() })
}
: item
)
@@ -38,6 +38,8 @@ export type TabsSlice = {
/** Client-local unified tab id to insert after; an explicit targetGroupId still wins. */
afterTabId: string
activate: boolean
/** false selects an activated tab without stamping its focus time (the group history still updates). */
recordFocus: boolean
recordInteraction: boolean
}
>
@@ -77,7 +79,11 @@ export type TabsSlice = {
entityId: string,
contentType?: TabContentType
) => Tab | null
activateTab: (tabId: string, opts?: { preservePreview?: boolean; worktreeId?: string }) => void
activateTab: (
tabId: string,
/** recordFocus false skips the focus-time stamp (not a user visit); the group history still updates. */
opts?: { preservePreview?: boolean; worktreeId?: string; recordFocus?: boolean }
) => void
closeUnifiedTab: (
tabId: string,
opts?: {
+7 -1
View File
@@ -1,6 +1,7 @@
import { PATH_EXISTENCE_BATCH_MAX } from '../path-existence-batch'
import { z } from 'zod'
import { QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS } from '../quick-open-path-search'
import { RUNTIME_NAVIGATION_TARGETS } from '../runtime-navigation'
import { FileOpen, WorktreeSelector } from './files-target-params'
export const FilePathsExist = WorktreeSelector.extend({
@@ -41,7 +42,12 @@ export const ResolveTerminalPath = WorktreeSelector.extend({
.optional()
})
export const FileOpenDiff = FileOpen.extend({
// Why: absent `navigation` (phones, older CLIs) keeps the original host switch; 'caller'/'clients' open without moving it.
export const FileOpenTab = FileOpen.extend({
navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional()
})
export const FileOpenDiff = FileOpenTab.extend({
staged: z.boolean().optional()
})
+2 -1
View File
@@ -171,6 +171,7 @@ import {
DocPreviewFileRead,
FileListAll,
FileOpenDiff,
FileOpenTab,
FilePathSearch,
FilePathsExist,
FileReadChunk,
@@ -762,7 +763,7 @@ export const RPC_PARAMS_BY_METHOD = {
'files.list': WorktreeSelector,
'files.listAll': FileListAll,
'files.listMarkdownDocuments': WorktreeSelector,
'files.open': FileOpen,
'files.open': FileOpenTab,
'files.openDiff': FileOpenDiff,
'files.pathsExist': FilePathsExist,
'files.read': FileOpen,