Prevent mobile worktree selection from focusing desktop (#6461)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-26 19:34:33 -07:00 committed by GitHub
parent ba88e27cdc
commit 5fd9f0c62c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 315 additions and 23 deletions

View File

@ -710,9 +710,12 @@ export function HostScreen({
// Highlight the row immediately; the next worktree.ps poll confirms it.
setOptimisticActiveWorktreeId(item.worktreeId)
if (client && connState === 'connected') {
// Why: opening a mobile session should hydrate host-owned tabs without
// pulling other paired clients, especially desktop, into this worktree.
void client
.sendRequest('worktree.activate', {
worktree: `id:${item.worktreeId}`
worktree: `id:${item.worktreeId}`,
notifyClients: false
})
.catch(() => null)
}

View File

@ -2624,11 +2624,12 @@ export default function SessionScreen() {
}
void (async () => {
if (client && created !== '1') {
// Why: desktop reveal can be slow on cold/busy hosts, but mobile
// session tabs are addressed by worktree id and can load immediately.
// Why: mobile needs host-owned tabs hydrated for this route, but should
// not pull other paired clients, especially desktop, into this worktree.
void client
.sendRequest('worktree.activate', {
worktree: `id:${worktreeId}`
worktree: `id:${worktreeId}`,
notifyClients: false
})
.catch(() => null)
}
@ -2653,7 +2654,8 @@ export default function SessionScreen() {
void (async () => {
await client
.sendRequest('worktree.activate', {
worktree: `id:${worktreeId}`
worktree: `id:${worktreeId}`,
notifyClients: false
})
.catch(() => null)
if (disposed) {
@ -2802,7 +2804,8 @@ export default function SessionScreen() {
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: matchingTab.id
tabId: matchingTab.id,
notifyClients: false
})
.catch(() => {})
}
@ -2834,7 +2837,8 @@ export default function SessionScreen() {
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: tab.id
tabId: tab.id,
notifyClients: false
})
.catch(() => {})
}
@ -2857,7 +2861,8 @@ export default function SessionScreen() {
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: tab.id
tabId: tab.id,
notifyClients: false
})
.catch(() => {})
}
@ -4137,7 +4142,8 @@ export default function SessionScreen() {
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: activePendingTerminalTab.id,
leafId: activePendingTerminalTab.leafId
leafId: activePendingTerminalTab.leafId,
notifyClients: false
})
.then((response) => {
if (!response.ok) {

View File

@ -36,6 +36,7 @@ describe('mobile session startup', () => {
)
expect(startupEffect).toContain("void client\n .sendRequest('worktree.activate'")
expect(startupEffect).toContain('notifyClients: false')
expect(startupEffect).not.toContain("await client\n .sendRequest('worktree.activate'")
expect(startupEffect.indexOf("sendRequest('worktree.activate'")).toBeLessThan(
startupEffect.indexOf('await fetchSessionTabs()')
@ -58,12 +59,22 @@ describe('mobile session startup', () => {
expect(pendingActivationEffect).toContain("sendRequest('session.tabs.activate'")
expect(pendingActivationEffect).toContain('tabId: activePendingTerminalTab.id')
expect(pendingActivationEffect).toContain('leafId: activePendingTerminalTab.leafId')
expect(pendingActivationEffect).toContain('notifyClients: false')
expect(pendingActivationEffect).toContain(
'applySessionTabs((response as RpcSuccess).result as SessionTabsResult)'
)
expect(pendingActivationEffect).toContain('scheduleDelayedAction(() => void fetchSessionTabs()')
})
it('keeps mobile session tab activation local to the phone', () => {
const activationRequests = source.split("sendRequest('session.tabs.activate'").slice(1)
expect(activationRequests).toHaveLength(4)
for (const request of activationRequests) {
expect(request.slice(0, request.indexOf('})'))).toContain('notifyClients: false')
}
})
it('keeps dynamic agent rows above fixed New Tab actions', () => {
const newTabActions = sliceBetween('title="New Tab"', 'onClose={() => setShowCreateTabDrawer')

View File

@ -0,0 +1,24 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('../../app/h/[hostId]/index.tsx', import.meta.url), 'utf8')
function sliceBetween(startPattern: string, endPattern: string): string {
const start = source.indexOf(startPattern)
expect(start).toBeGreaterThanOrEqual(0)
const end = source.indexOf(endPattern, start)
expect(end).toBeGreaterThan(start)
return source.slice(start, end)
}
describe('mobile worktree activation', () => {
it('opens mobile sessions without foregrounding other paired clients', () => {
const openSession = sliceBetween(
'const openWorktreeSession = useCallback(',
'const handleSortChange = useCallback'
)
expect(openSession).toContain("sendRequest('worktree.activate'")
expect(openSession).toContain('notifyClients: false')
})
})

View File

@ -11775,6 +11775,77 @@ describe('OrcaRuntimeService', () => {
expect(focusTerminal).toHaveBeenCalledWith('tab-1', TEST_WORKTREE_ID, 'pane:2')
})
it('activates mobile session tabs without focusing desktop clients when requested', async () => {
const focusTerminal = vi.fn()
const runtime = new OrcaRuntimeService(store)
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession: vi.fn(),
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal,
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: 'tab-1::pane:2',
activeTabType: 'terminal',
tabGroups: [{ id: 'group-1', activeTabId: 'tab-1', tabOrder: ['tab-1'] }],
tabs: [
{
type: 'terminal',
id: 'tab-1::pane:1',
parentTabId: 'tab-1',
leafId: 'pane:1',
title: 'left',
isActive: false
},
{
type: 'terminal',
id: 'tab-1::pane:2',
parentTabId: 'tab-1',
leafId: 'pane:2',
title: 'right',
isActive: true
}
]
}
]
})
const activated = await runtime.activateMobileSessionTab(
`id:${TEST_WORKTREE_ID}`,
'tab-1::pane:1',
undefined,
{ notifyClients: false }
)
expect(focusTerminal).not.toHaveBeenCalled()
expect(activated).toMatchObject({
activeTabId: 'tab-1::pane:1',
activeTabType: 'terminal',
tabGroups: [expect.objectContaining({ id: 'group-1', activeTabId: 'tab-1' })]
})
expect(activated.tabs).toEqual([
expect.objectContaining({ id: 'tab-1::pane:1', isActive: true }),
expect.objectContaining({ id: 'tab-1::pane:2', isActive: false })
])
})
it('closes browser mobile session tabs when addressed by browser workspace id', async () => {
const closeSessionTab = vi.fn()
const runtime = new OrcaRuntimeService(store)

View File

@ -3658,7 +3658,8 @@ export class OrcaRuntimeService {
async activateMobileSessionTab(
worktreeSelector: string,
tabId: string,
leafId?: string
leafId?: string,
opts: { notifyClients?: boolean } = {}
): Promise<RuntimeMobileSessionTabsResult> {
const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector)
const worktreeId =
@ -3733,6 +3734,10 @@ export class OrcaRuntimeService {
candidate.isActive
)
const targetTab = activeSibling ?? tab
if (opts.notifyClients === false) {
this.activateMobileSessionTabForRemoteClient(worktreeId, snapshot!, targetTab)
return this.getMobileSessionTabsForWorktree(worktreeId)
}
if (!this.notifier?.focusTerminal) {
if (
!targetTab.isActive &&
@ -3744,15 +3749,57 @@ export class OrcaRuntimeService {
}
this.notifier?.focusTerminal(targetTab.parentTabId, worktreeId, targetTab.leafId)
} else if (tab.type === 'browser') {
if (opts.notifyClients === false) {
this.activateMobileSessionTabForRemoteClient(worktreeId, snapshot!, tab)
return this.getMobileSessionTabsForWorktree(worktreeId)
}
// Why: browser mobile tabs are renderer-owned unified tabs; focusing the
// session tab keeps desktop tab order/group state authoritative.
this.notifier?.focusEditorTab?.(tab.id, worktreeId)
} else {
if (opts.notifyClients === false) {
this.activateMobileSessionTabForRemoteClient(worktreeId, snapshot!, tab)
return this.getMobileSessionTabsForWorktree(worktreeId)
}
this.notifier?.focusEditorTab?.(tab.id, worktreeId)
}
return this.getMobileSessionTabsForWorktree(worktreeId)
}
private activateMobileSessionTabForRemoteClient(
worktreeId: string,
snapshot: RuntimeMobileSessionTabsSnapshot,
activeTab: RuntimeMobileSessionSnapshotTab
): void {
// Why: phone tab selection should update the mobile snapshot without
// asking desktop renderers to focus the phone's background worktree.
const activeTopLevelId = activeTab.type === 'terminal' ? activeTab.parentTabId : activeTab.id
const tabs = snapshot.tabs.map((tab) => ({
...tab,
isActive: tab.id === activeTab.id
}))
const tabGroups = snapshot.tabGroups?.map((group) =>
group.tabOrder.includes(activeTopLevelId)
? { ...group, activeTabId: activeTopLevelId }
: group
)
const activeGroupId =
tabGroups?.find((group) => group.tabOrder.includes(activeTopLevelId))?.id ??
snapshot.activeGroupId
const nextSnapshot: RuntimeMobileSessionTabsSnapshot = {
...snapshot,
publicationEpoch: `mobile-local:${Date.now().toString(36)}`,
snapshotVersion: snapshot.snapshotVersion + 1,
activeGroupId,
activeTabId: activeTab.id,
activeTabType: activeTab.type,
...(tabGroups ? { tabGroups } : {}),
tabs
}
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
this.emitMobileSessionTabsSnapshot(nextSnapshot)
}
private shouldMaterializeHeadlessMobileSessionTab(
snapshot: RuntimeMobileSessionTabsSnapshot,
tab: RuntimeMobileSessionTerminalTab
@ -11533,7 +11580,10 @@ export class OrcaRuntimeService {
return { worktreeId: worktree.id }
}
async activateManagedWorktree(worktreeSelector: string): Promise<{
async activateManagedWorktree(
worktreeSelector: string,
opts: { notifyClients?: boolean } = {}
): Promise<{
repoId: string
worktreeId: string
activated: boolean
@ -11545,9 +11595,19 @@ export class OrcaRuntimeService {
throw new Error('repo_not_found')
}
// Why: inactive worktree terminal panes are renderer-owned and may not have
// live PTYs until the desktop activates the worktree and mounts them.
this.notifyActivateWorktree(repo.id, worktree.id)
if (opts.notifyClients !== false) {
// Why: inactive worktree terminal panes are renderer-owned and may not have
// live PTYs until the desktop activates the worktree and mounts them.
this.notifyActivateWorktree(repo.id, worktree.id)
} else {
// Why: mobile/web selection needs fresh session surfaces without forcing
// every attached desktop renderer to navigate to the phone's workspace.
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, {
allowAttachedWindow: true
})
await this.refreshMobileSessionPtyRecords()
this.notifyMobileSessionTabsChanged(worktree.id)
}
return { repoId: repo.id, worktreeId: worktree.id, activated: true }
}

View File

@ -2,6 +2,7 @@ import { z } from 'zod'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import type { TuiAgent } from '../../../../shared/types'
import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents'
import { OptionalBoolean } from '../schemas'
export const WorktreeTabSelector = z.object({
worktree: z
@ -19,7 +20,8 @@ export const ActivateTab = WorktreeTabSelector.extend({
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing tab id')),
leafId: z.string().max(128).optional()
leafId: z.string().max(128).optional(),
notifyClients: OptionalBoolean
})
export type TerminalPaneLayoutNodeInput =

View File

@ -9,6 +9,36 @@ function makeRequest(method: string, params?: unknown): RpcRequest {
}
describe('session tab RPC methods', () => {
it('routes mobile-only activation without notifying desktop clients', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
activateMobileSessionTab: vi.fn().mockResolvedValue({
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: 'tab-1',
activeTabType: 'terminal',
tabs: []
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('session.tabs.activate', {
worktree: 'id:wt-1',
tabId: 'tab-1',
leafId: 'leaf-1',
notifyClients: false
})
)
expect(response.ok).toBe(true)
expect(runtime.activateMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', 'leaf-1', {
notifyClients: false
})
})
it('dispatches tab moves through the runtime', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',

View File

@ -28,7 +28,9 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
name: 'session.tabs.activate',
params: ActivateTab,
handler: async (params, { runtime }) =>
runtime.activateMobileSessionTab(params.worktree, params.tabId, params.leafId)
runtime.activateMobileSessionTab(params.worktree, params.tabId, params.leafId, {
notifyClients: params.notifyClients !== false
})
}),
defineMethod({
name: 'session.tabs.close',

View File

@ -55,6 +55,10 @@ export const WorktreeSelector = z.object({
.pipe(z.string().min(1, 'Missing worktree selector'))
})
export const WorktreeActivate = WorktreeSelector.extend({
notifyClients: OptionalBoolean
})
export const WorktreeCreate = z
.object({
repo: z

View File

@ -20,6 +20,28 @@ function makeRequest(method: string, params?: unknown): RpcRequest {
}
describe('worktree RPC methods', () => {
it('routes mobile session-only activation without notifying desktop clients', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
activateManagedWorktree: vi
.fn()
.mockResolvedValue({ repoId: 'repo-1', worktreeId: 'wt-1', activated: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('worktree.activate', {
worktree: 'id:wt-1',
notifyClients: false
})
)
expect(response).toMatchObject({ ok: true })
expect(runtime.activateManagedWorktree).toHaveBeenCalledWith('id:wt-1', {
notifyClients: false
})
})
it('routes create options to the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',

View File

@ -7,6 +7,7 @@ import { defineMethod, type RpcMethod } from '../core'
import {
WorktreeCreate,
WorktreeDetectedListParams,
WorktreeActivate,
WorktreeForceDeleteBranch,
WorktreeListParams,
WorktreePrefetchCreateBase,
@ -57,8 +58,11 @@ export const WORKTREE_METHODS: RpcMethod[] = [
}),
defineMethod({
name: 'worktree.activate',
params: WorktreeSelector,
handler: async (params, { runtime }) => runtime.activateManagedWorktree(params.worktree)
params: WorktreeActivate,
handler: async (params, { runtime }) =>
runtime.activateManagedWorktree(params.worktree, {
notifyClients: params.notifyClients !== false
})
}),
defineMethod({
name: 'worktree.create',

View File

@ -364,7 +364,7 @@ describe('activateAndRevealWorktree created agent reopen', () => {
expect(callRuntimeEnvironment).toHaveBeenCalledWith({
selector: 'web-runtime-1',
method: 'worktree.activate',
params: { worktree: `id:${worktree.id}` },
params: { worktree: `id:${worktree.id}`, notifyClients: false },
timeoutMs: 15_000
})
})

View File

@ -306,11 +306,12 @@ export function activateAndRevealWorktree(
const ownerRuntimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(postActivationState, wt.id)
if (opts?.notifyHostRuntime !== false && isWebRuntimeSessionActive(ownerRuntimeEnvironmentId)) {
// Why: paired web clients own only local selection state. The desktop host
// must also activate the worktree so hidden renderer-owned terminal panes
// mount and publish session surfaces back to the web client.
// should publish session surfaces for the phone without treating that as a
// desktop navigation command.
void activateWebRuntimeSessionWorktree({
worktreeId,
environmentId: ownerRuntimeEnvironmentId
environmentId: ownerRuntimeEnvironmentId,
notifyDesktop: (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ !== true
})
}

View File

@ -2,6 +2,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import {
activateWebRuntimeSessionWorktree,
activateWebRuntimeSessionTab,
closeWebRuntimeTerminal,
closeWebRuntimeSessionTab,
@ -62,6 +63,55 @@ function makeSnapshot(): RuntimeMobileSessionTabsResult {
}
}
describe('activateWebRuntimeSessionWorktree', () => {
beforeEach(() => {
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)
mocks.getState.mockReturnValue({
settings: {
activeRuntimeEnvironmentId: ENVIRONMENT_ID
}
})
})
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it('can ask the host to activate session surfaces without notifying desktop clients', async () => {
const runtimeCall = vi.fn().mockResolvedValueOnce({
id: 'activate',
ok: true,
result: { repoId: 'repo', worktreeId: WORKTREE_ID, activated: true }
})
vi.stubGlobal('window', {
api: {
runtimeEnvironments: {
call: runtimeCall
}
}
})
await expect(
activateWebRuntimeSessionWorktree({
worktreeId: WORKTREE_ID,
notifyDesktop: false
})
).resolves.toBe(true)
expect(runtimeCall).toHaveBeenCalledWith({
selector: ENVIRONMENT_ID,
method: 'worktree.activate',
params: {
worktree: `id:${WORKTREE_ID}`,
notifyClients: false
},
timeoutMs: 15_000
})
})
})
describe('createWebRuntimeSessionBrowserTab', () => {
beforeEach(() => {
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)

View File

@ -282,6 +282,7 @@ async function refreshWebRuntimeSessionTabsSnapshot(
export async function activateWebRuntimeSessionWorktree(args: {
worktreeId: string
environmentId?: string | null
notifyDesktop?: boolean
}): Promise<boolean> {
const environmentId =
args.environmentId?.trim() ??
@ -296,7 +297,8 @@ export async function activateWebRuntimeSessionWorktree(args: {
selector: environmentId,
method: 'worktree.activate',
params: {
worktree: toRuntimeWorktreeSelector(args.worktreeId)
worktree: toRuntimeWorktreeSelector(args.worktreeId),
notifyClients: args.notifyDesktop !== false
},
timeoutMs: 15_000
})