diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index bd5b640cd3a..ca2e9ba81a4 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -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 }) diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index fb858e9762a..854063c70d4 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -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['gitStatusByWork const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntries = [] const EMPTY_AGENT_CMD_OVERRIDES: Partial> = {} 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 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 ? ( - - - New Mobile Emulator - {newSimulatorShortcut} - + workspaceHasSimulatorTab ? ( + + + + + + New Mobile Emulator + {newSimulatorShortcut} + + + + + {EMULATOR_EXISTS_TOOLTIP} + + + ) : ( + + + New Mobile Emulator + {newSimulatorShortcut} + + ) ) : null const newMarkdownMenuItem = !terminalOnly && onNewFileTab ? ( diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index b4a1bce884b..5e799adb34f 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -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 diff --git a/src/renderer/src/lib/ensure-simulator-tab.ts b/src/renderer/src/lib/ensure-simulator-tab.ts index f5b4e2356ec..4c226abb0fb 100644 --- a/src/renderer/src/lib/ensure-simulator-tab.ts +++ b/src/renderer/src/lib/ensure-simulator-tab.ts @@ -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) { diff --git a/src/renderer/src/lib/open-mobile-emulator-tab.test.ts b/src/renderer/src/lib/open-mobile-emulator-tab.test.ts index da03ae571af..1f6b9ea83f4 100644 --- a/src/renderer/src/lib/open-mobile-emulator-tab.test.ts +++ b/src/renderer/src/lib/open-mobile-emulator-tab.test.ts @@ -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, @@ -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 } diff --git a/src/renderer/src/lib/open-mobile-emulator-tab.ts b/src/renderer/src/lib/open-mobile-emulator-tab.ts index 1e4d2ea2b7d..dccfa0b8247 100644 --- a/src/renderer/src/lib/open-mobile-emulator-tab.ts +++ b/src/renderer/src/lib/open-mobile-emulator-tab.ts @@ -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] ??