fix: disable duplicate mobile emulator launch

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-07 21:00:58 -07:00
co-authored by Orca
parent 682a4f9894
commit aecb2ca000
6 changed files with 115 additions and 14 deletions
@@ -73,6 +73,9 @@ vi.mock('lucide-react', () => ({
Plus: function Plus() {
return null
},
Smartphone: function Smartphone() {
return null
},
TerminalSquare: function TerminalSquare() {
return null
}
@@ -420,6 +423,43 @@ describe('TabBar context menu wiring', () => {
expect(menuLabels[3]).toContain('New Browser Tab')
})
it('disables New Mobile Emulator when the workspace already has one', async () => {
const onNewSimulatorTab = vi.fn()
appStoreSnapshot.unifiedTabsByWorktree = {
'wt-1': [
{
id: 'sim-1',
entityId: 'sim-1',
groupId: 'group-2',
worktreeId: 'wt-1',
contentType: 'simulator',
label: 'Mobile Emulator',
customLabel: null,
color: null,
sortOrder: 1,
createdAt: 0
}
]
}
const element = await renderTabBar({
tabs: [TERMINAL_TAB],
groupId: 'group-1',
onNewSimulatorTab
})
const emulatorItem = findChildrenByType(element, 'DropdownMenuItem').find((item) =>
extractText(item.props.children).includes('New Mobile Emulator')
)
expect(emulatorItem?.props.disabled).toBe(true)
expect(emulatorItem?.props.onSelect).toBeUndefined()
const tooltip = findChildrenByType(element, 'TooltipContent').find((item) =>
extractText(item.props.children).includes('An emulator already exists in this workspace.')
)
expect(tooltip).toBeTruthy()
})
it('cancels delayed menu focus when the tab bar root unmounts', async () => {
vi.useFakeTimers()
Object.assign(window, { setTimeout, clearTimeout })
+34 -8
View File
@@ -51,6 +51,7 @@ import {
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { TabCreateEntryArgs } from './tab-create-entry-action'
import { buildTabAgentLaunchOptions, orderTabLaunchAgents } from './tab-agent-launch-options'
@@ -62,6 +63,7 @@ type GitStatusEntries = ReturnType<typeof useAppStore.getState>['gitStatusByWork
const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntries = []
const EMPTY_AGENT_CMD_OVERRIDES: Partial<Record<TuiAgent, string>> = {}
const EMPTY_UNIFIED_TABS: readonly Tab[] = []
const EMULATOR_EXISTS_TOOLTIP = 'An emulator already exists in this workspace.'
type TabBarProps = {
tabs: (TerminalTab & { unifiedTabId?: string })[]
@@ -293,6 +295,10 @@ function TabBarInner({
() => createUnifiedTabLookup(unifiedTabs, resolvedGroupId),
[resolvedGroupId, unifiedTabs]
)
const workspaceHasSimulatorTab = useMemo(
() => unifiedTabs.some((tab) => tab.contentType === 'simulator'),
[unifiedTabs]
)
// Why: Electron <webview> elements run in a separate process, so clicking
// inside one never dispatches a pointerdown on the renderer document.
@@ -489,14 +495,34 @@ function TabBarInner({
) : null
const newSimulatorMenuItem =
!terminalOnly && isMacOs && mobileEmulatorEnabled && onNewSimulatorTab ? (
<DropdownMenuItem
onSelect={onNewSimulatorTab}
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
>
<Smartphone className="size-4 text-muted-foreground" />
New Mobile Emulator
<DropdownMenuShortcut>{newSimulatorShortcut}</DropdownMenuShortcut>
</DropdownMenuItem>
workspaceHasSimulatorTab ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="block">
<DropdownMenuItem
disabled
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
>
<Smartphone className="size-4 text-muted-foreground" />
New Mobile Emulator
<DropdownMenuShortcut>{newSimulatorShortcut}</DropdownMenuShortcut>
</DropdownMenuItem>
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8} className="z-[80]">
{EMULATOR_EXISTS_TOOLTIP}
</TooltipContent>
</Tooltip>
) : (
<DropdownMenuItem
onSelect={onNewSimulatorTab}
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
>
<Smartphone className="size-4 text-muted-foreground" />
New Mobile Emulator
<DropdownMenuShortcut>{newSimulatorShortcut}</DropdownMenuShortcut>
</DropdownMenuItem>
)
) : null
const newMarkdownMenuItem =
!terminalOnly && onNewFileTab ? (
@@ -578,7 +578,7 @@ export function useTabGroupWorkspaceModel({
newSimulatorTab: worktreeState.mobileEmulatorEnabled
? () => {
// Why: mobile simulators are most useful beside the current tab group.
// Re-open focuses the existing worktree simulator instead of spawning duplicates.
// Re-open is guarded as a no-op once a worktree emulator tab exists.
void openMobileEmulatorTab(worktreeId, {
placement: 'rightSplit',
targetGroupId: groupId
+15 -3
View File
@@ -11,6 +11,20 @@ type EnsureSimulatorTabOptions = {
surfacePane?: boolean
}
type ExistingSimulatorTab = {
id: string
groupId: string
contentType: string
}
export function getSimulatorTabForWorktree(worktreeId: string): ExistingSimulatorTab | null {
return (
(useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).find(
(tab) => tab.contentType === 'simulator'
) ?? null
)
}
/** One simulator tab per worktree; focuses existing tab instead of creating duplicates. */
export function ensureSimulatorTab(
worktreeId: string,
@@ -32,9 +46,7 @@ export function ensureSimulatorTab(
}
cancelPendingSimulatorPaneShutdown(worktreeId)
const existing = (store.unifiedTabsByWorktree[worktreeId] ?? []).find(
(tab) => tab.contentType === 'simulator'
)
const existing = getSimulatorTabForWorktree(worktreeId)
const shouldSurface = options?.surfacePane ?? true
if (existing) {
if (shouldSurface && store.activeWorktreeId === worktreeId) {
@@ -6,7 +6,7 @@ import {
consumePrelaunchedSimulatorSession,
isManualSimulatorLaunchPending
} from './simulator-launch-coordination'
import { ensureSimulatorTab } from './ensure-simulator-tab'
import { ensureSimulatorTab, getSimulatorTabForWorktree } from './ensure-simulator-tab'
const mockStoreState = vi.hoisted(() => ({
activeGroupIdByWorktree: { 'wt-1': 'group-1' } as Record<string, string>,
@@ -32,6 +32,7 @@ vi.mock('sonner', () => ({
vi.mock('./ensure-simulator-tab', () => ({
ensureSimulatorTab: vi.fn(),
getSimulatorTabForWorktree: vi.fn(),
isMacOsHost: true
}))
@@ -51,6 +52,8 @@ describe('openMobileEmulatorTab', () => {
mockStoreState.settings = { mobileEmulatorEnabled: true }
vi.mocked(callRuntimeRpc).mockReset()
vi.mocked(ensureSimulatorTab).mockReset()
vi.mocked(getSimulatorTabForWorktree).mockReset()
vi.mocked(getSimulatorTabForWorktree).mockReturnValue(null)
vi.mocked(toast.error).mockReset()
consumePrelaunchedSimulatorSession('wt-1')
})
@@ -130,6 +133,22 @@ describe('openMobileEmulatorTab', () => {
await firstLaunch
})
it('does nothing when the workspace already has an emulator tab', async () => {
vi.mocked(getSimulatorTabForWorktree).mockReturnValue({
id: 'sim-existing',
groupId: 'group-1',
contentType: 'simulator'
})
await expect(openMobileEmulatorTab('wt-1', { targetGroupId: 'group-1' })).resolves.toBe(
'sim-existing'
)
expect(ensureSimulatorTab).not.toHaveBeenCalled()
expect(callRuntimeRpc).not.toHaveBeenCalled()
expect(isManualSimulatorLaunchPending('wt-1')).toBe(false)
})
it('does not attach when the mobile emulator feature is disabled', async () => {
mockStoreState.settings = { mobileEmulatorEnabled: false }
@@ -2,7 +2,7 @@ import { toast } from 'sonner'
import type { EmulatorStreamInfo } from '@/components/emulator-pane/emulator-pane-types'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { useAppStore } from '@/store'
import { ensureSimulatorTab, isMacOsHost } from './ensure-simulator-tab'
import { ensureSimulatorTab, getSimulatorTabForWorktree, isMacOsHost } from './ensure-simulator-tab'
import {
beginManualSimulatorLaunch,
dispatchManualSimulatorLaunchFailed,
@@ -54,6 +54,10 @@ export async function openMobileEmulatorTab(
if (store.settings?.mobileEmulatorEnabled === false) {
return null
}
const existingTab = getSimulatorTabForWorktree(worktreeId)
if (existingTab) {
return existingTab.id
}
const targetGroupId =
options.targetGroupId ??
store.activeGroupIdByWorktree[worktreeId] ??