mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
fix: disable duplicate mobile emulator launch
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] ??
|
||||
|
||||
Reference in New Issue
Block a user