diff --git a/apps/sim/hooks/use-canvas-viewport.ts b/apps/sim/hooks/use-canvas-viewport.ts
index bfa6b3f6ce2..85bebc2d91c 100644
--- a/apps/sim/hooks/use-canvas-viewport.ts
+++ b/apps/sim/hooks/use-canvas-viewport.ts
@@ -1,7 +1,7 @@
import { useCallback, useMemo } from 'react'
import { BLOCK_DIMENSIONS } from '@sim/workflow-renderer'
import type { Node, ReactFlowInstance } from '@xyflow/react'
-import { getTerminalHeight } from '@/lib/workflows/layout'
+import { getPanelWidth, getTerminalHeight } from '@/lib/workflows/layout'
interface VisibleBounds {
width: number
@@ -39,7 +39,7 @@ function getVisibleCanvasBounds(options?: CanvasViewportOptions): VisibleBounds
const sidebarWidth = Number.parseInt(style.getPropertyValue('--sidebar-width') || '0', 10)
const terminalHeight = getTerminalHeight()
- const panelWidth = Number.parseInt(style.getPropertyValue('--panel-width') || '0', 10)
+ const panelWidth = getPanelWidth()
if (!flowContainer) {
return {
diff --git a/apps/sim/hooks/use-is-mobile.ts b/apps/sim/hooks/use-is-mobile.ts
index bac1c38c962..a01a16278c8 100644
--- a/apps/sim/hooks/use-is-mobile.ts
+++ b/apps/sim/hooks/use-is-mobile.ts
@@ -2,7 +2,7 @@
import { useSyncExternalStore } from 'react'
-const MOBILE_QUERY = '(max-width: 767px)'
+const MOBILE_QUERY = '(width < 768px)'
function subscribe(onChange: () => void) {
const query = window.matchMedia(MOBILE_QUERY)
diff --git a/apps/sim/lib/workflows/layout.ts b/apps/sim/lib/workflows/layout.ts
index 59e3982e03f..bcfee6f9ce2 100644
--- a/apps/sim/lib/workflows/layout.ts
+++ b/apps/sim/lib/workflows/layout.ts
@@ -1,3 +1,15 @@
+/** Reads the rendered workflow panel width, including responsive bounds and live resizing. */
+export function getPanelWidth(): number {
+ if (typeof document === 'undefined') return 0
+
+ const panel = document.querySelector('.panel-container')
+ if (panel) return panel.getBoundingClientRect().width
+
+ return Number.parseFloat(
+ getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
+ )
+}
+
/** Reads the rendered terminal height, falling back to saved layout before it mounts. */
export function getTerminalHeight(): number {
if (typeof document === 'undefined') return 0
diff --git a/apps/sim/scripts/test-mobile-e2e.ts b/apps/sim/scripts/test-mobile-e2e.ts
index 0272dbc8d8c..e98317b3a34 100644
--- a/apps/sim/scripts/test-mobile-e2e.ts
+++ b/apps/sim/scripts/test-mobile-e2e.ts
@@ -842,7 +842,7 @@ async function exerciseViewport(
) {
await capture(page, `${browserName}-${viewport.name}-resources`)
}
- if (viewport.width < 1000) {
+ if (viewport.width < 768) {
await expect(composer).toBeHidden()
await expectContained(page, backToChat)
await backToChat.click()
@@ -855,6 +855,45 @@ async function exerciseViewport(
composerBounds.x + composerBounds.width <= resourceBounds.x,
'Wide screens must retain side-by-side chat and resources'
)
+ try {
+ if (viewport.name === 'tablet') {
+ await page.setViewportSize({ width: 768, height: viewport.height })
+ }
+ const divider = page.getByRole('separator', {
+ name: 'Resize resource view',
+ exact: true,
+ })
+ for (const key of ['Home', 'End']) {
+ await divider.press(key)
+ await expect(async () => {
+ const measurement = await divider.evaluate((element) => {
+ const panel = document.querySelector('[data-mothership-panel]')
+ if (!panel) throw new Error('Resource panel is unavailable')
+ return {
+ width: panel.getBoundingClientRect().width,
+ min: Number.parseFloat(element.getAttribute('aria-valuemin') ?? ''),
+ now: Number.parseFloat(element.getAttribute('aria-valuenow') ?? ''),
+ max: Number.parseFloat(element.getAttribute('aria-valuemax') ?? ''),
+ }
+ })
+ const { width, min, now, max } = measurement
+ assert(
+ min <= now && now <= max,
+ `The panel width must stay within its accessible range: ${JSON.stringify(measurement)}`
+ )
+ assert(
+ Math.abs(now - width) <= 1,
+ `The resize value must describe the visible panel: ${JSON.stringify(measurement)}`
+ )
+ }).toPass({ timeout: 20_000 })
+ await expectContained(page, composer)
+ await expectNoOverflow(page)
+ }
+ } finally {
+ if (viewport.name === 'tablet') {
+ await page.setViewportSize({ width: viewport.width, height: viewport.height })
+ }
+ }
await page.getByRole('button', { name: 'Collapse resource view', exact: true }).click()
}
await expect(composer).toHaveValue(
@@ -1109,25 +1148,25 @@ async function exerciseViewport(
await expectNoOverflow(page)
}
)
- await check(`${prefix}/workflow controls open and return to the canvas`, page, async () => {
- await visit(page, `w/${workflowId}`)
- await expect(page.locator('.react-flow')).toBeVisible({ timeout: 120_000 })
- const panel = page.getByRole('complementary', { name: 'Workflow panel', exact: true })
- await expect(panel).toBeHidden()
- await capture(page, `${browserName}-${viewport.name}-workflow-canvas`)
- await page.getByRole('button', { name: 'Workflow', exact: true }).tap()
- await expectContained(page, panel)
- await capture(page, `${browserName}-${viewport.name}-workflow-panel`)
- await page.getByRole('button', { name: 'Back to canvas', exact: true }).tap()
- await expect(panel).toBeHidden()
- await expect
- .poll(async () => {
- const canvas = await page.locator('.react-flow').boundingBox()
- const content = await contentShell(page).boundingBox()
- return canvas && content ? canvas.width / content.width : 0
- })
- .toBeGreaterThan(0.95)
- if (viewport.name === 'phone') {
+ if (viewport.name === 'phone') {
+ await check(`${prefix}/workflow controls open and return to the canvas`, page, async () => {
+ await visit(page, `w/${workflowId}`)
+ await expect(page.locator('.react-flow')).toBeVisible({ timeout: 120_000 })
+ const panel = page.getByRole('complementary', { name: 'Workflow panel', exact: true })
+ await expect(panel).toBeHidden()
+ await capture(page, `${browserName}-${viewport.name}-workflow-canvas`)
+ await page.getByRole('button', { name: 'Workflow', exact: true }).tap()
+ await expectContained(page, panel)
+ await capture(page, `${browserName}-${viewport.name}-workflow-panel`)
+ await page.getByRole('button', { name: 'Back to canvas', exact: true }).tap()
+ await expect(panel).toBeHidden()
+ await expect
+ .poll(async () => {
+ const canvas = await page.locator('.react-flow').boundingBox()
+ const content = await contentShell(page).boundingBox()
+ return canvas && content ? canvas.width / content.width : 0
+ })
+ .toBeGreaterThan(0.95)
await sql`update workflow set locked = true where id = ${workflowId}`
await withDocumentNavigation(page, page.url(), () =>
page.reload({ waitUntil: 'commit', timeout: 360_000 })
@@ -1159,9 +1198,55 @@ async function exerciseViewport(
return rows[0]?.locked
})
.toBe(false)
+ await expectNoOverflow(page)
+ })
+ }
+ }
+
+ if (viewport.name === 'phone' || viewport.name === 'tablet' || viewport.name === 'desktop') {
+ await check(
+ `${prefix}/workflow uses compact phone controls and regular desktop panels`,
+ page,
+ async () => {
+ await visit(page, `w/${workflowId}`)
+ const canvas = page.locator('.react-flow:visible')
+ await expect(canvas).toBeVisible({ timeout: 120_000 })
+ const panel = page.getByRole('complementary', { name: 'Workflow panel', exact: true })
+ if (viewport.width < 768) {
+ await page.getByRole('button', { name: 'Workflow', exact: true }).tap()
+ }
+ await expectContained(page, panel)
+ const actions = panel.getByRole('button', { name: 'Workflow actions', exact: true })
+ const run = panel.getByRole('button', { name: 'Run', exact: true })
+ const deploy = panel.getByRole('button', { name: 'Deploy', exact: true })
+ for (const control of [actions, run, deploy]) {
+ const bounds = await control.boundingBox()
+ assert(bounds && bounds.height === 30, 'Workflow action faces must stay compact')
+ }
+ if (viewport.width < 768) {
+ const bounds = await actions.boundingBox()
+ assert(bounds)
+ await page.touchscreen.tap(bounds.x + bounds.width / 2, bounds.y - 5)
+ await expect(
+ page.getByRole('menuitem', { name: 'Auto layout', exact: true })
+ ).toBeVisible()
+ await page.keyboard.press('Escape')
+ } else {
+ const canvasBounds = await canvas.boundingBox()
+ const panelBounds = await panel.boundingBox()
+ assert(canvasBounds && panelBounds)
+ assert(
+ canvasBounds.width > 0 && canvasBounds.x + canvasBounds.width <= panelBounds.x + 1,
+ 'Regular views must keep the canvas beside the workflow panel'
+ )
+ await expect(
+ page.getByRole('button', { name: 'Back to canvas', exact: true })
+ ).toBeHidden()
+ }
+ await expectNoOverflow(page)
+ await capture(page, `${browserName}-${viewport.name}-workflow-regular-layout`)
}
- await expectNoOverflow(page)
- })
+ )
}
} finally {
documentNavigations.delete(page)
diff --git a/apps/sim/stores/chat/utils.ts b/apps/sim/stores/chat/utils.ts
index ee0dd4c793c..8936b5076c3 100644
--- a/apps/sim/stores/chat/utils.ts
+++ b/apps/sim/stores/chat/utils.ts
@@ -1,4 +1,4 @@
-import { getTerminalHeight } from '@/lib/workflows/layout'
+import { getPanelWidth, getTerminalHeight } from '@/lib/workflows/layout'
import type { ChatPosition } from '@/stores/chat/types'
/**
@@ -30,9 +30,7 @@ const calculateDefaultPosition = (width: number): ChatPosition => {
return { x: 100, y: 100 }
}
- const panelWidth = Number.parseInt(
- getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
- )
+ const panelWidth = getPanelWidth()
const x = window.innerWidth - CONTENT_WINDOW_GAP - panelWidth - 32 - width
const y = CONTENT_WINDOW_GAP + 32
@@ -55,9 +53,7 @@ export const constrainChatPosition = (
const sidebarWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
)
- const panelWidth = Number.parseInt(
- getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
- )
+ const panelWidth = getPanelWidth()
const terminalHeight = getTerminalHeight()
const minX = sidebarWidth
diff --git a/apps/sim/stores/constants.ts b/apps/sim/stores/constants.ts
index 83694a563b8..ad4ca0d1e46 100644
--- a/apps/sim/stores/constants.ts
+++ b/apps/sim/stores/constants.ts
@@ -70,7 +70,8 @@ export const MOTHERSHIP_WIDTH = {
MAX_PERCENTAGE: 0.8,
/**
* Narrowest the chat column beside the panel may be laid out at — the
- * `min-w-[min(480px,100%)]` class on that column in home.tsx, so the two must agree.
+ * `min-w-[min(480px,50%)]` class on that column in home.tsx yields to half
+ * the available space when the regular layout is narrow.
*
* The panel is what yields to it: the chat's flex-basis is 0, so the whole of
* any negative free space is taken out of the panel. A width written past
diff --git a/apps/sim/stores/variables/modal.ts b/apps/sim/stores/variables/modal.ts
index ac72b3cb60c..c31c6227e23 100644
--- a/apps/sim/stores/variables/modal.ts
+++ b/apps/sim/stores/variables/modal.ts
@@ -1,6 +1,6 @@
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
-import { getTerminalHeight } from '@/lib/workflows/layout'
+import { getPanelWidth, getTerminalHeight } from '@/lib/workflows/layout'
import type { VariablesModalStore, VariablesPosition } from '@/stores/variables/types'
/**
@@ -33,9 +33,7 @@ const calculateDefaultPosition = (): VariablesPosition => {
const sidebarWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
)
- const panelWidth = Number.parseInt(
- getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
- )
+ const panelWidth = getPanelWidth()
const terminalHeight = getTerminalHeight()
const availableWidth = window.innerWidth - sidebarWidth - CONTENT_WINDOW_GAP - panelWidth
@@ -58,9 +56,7 @@ const constrainPosition = (
const sidebarWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
)
- const panelWidth = Number.parseInt(
- getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
- )
+ const panelWidth = getPanelWidth()
const terminalHeight = getTerminalHeight()
const minX = sidebarWidth
diff --git a/packages/emcn/src/components/button/button.tsx b/packages/emcn/src/components/button/button.tsx
index 928a4f7a61f..b575032896d 100644
--- a/packages/emcn/src/components/button/button.tsx
+++ b/packages/emcn/src/components/button/button.tsx
@@ -1,6 +1,10 @@
import { type ButtonHTMLAttributes, forwardRef } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/cn'
+import {
+ MobileTouchTarget,
+ mobileTouchTargetClass,
+} from '../mobile-touch-target/mobile-touch-target'
/**
* `size='icon'` is the square 20px icon-only button — a chip field's trailing
@@ -75,6 +79,8 @@ const buttonVariants = cva(
export interface ButtonProps
extends ButtonHTMLAttributes,
VariantProps {
+ /** Reserves a 44px mobile hit target while preserving the button's visible size. */
+ mobileCompact?: boolean
/**
* Symmetric padding for icon actions whose content or layout determines their size.
* Preserves the selected size's typography, corner radius and icon stroke.
@@ -85,13 +91,19 @@ export interface ButtonProps
}
const Button = forwardRef(
- ({ className, variant, size, iconPadding, ...props }, ref) => {
+ ({ className, variant, size, iconPadding, mobileCompact, ...props }, ref) => {
return (
-
+
+
+
)
}
)
diff --git a/packages/emcn/src/components/chip/chip.tsx b/packages/emcn/src/components/chip/chip.tsx
index 3227b0bfa56..56bab6ef7df 100644
--- a/packages/emcn/src/components/chip/chip.tsx
+++ b/packages/emcn/src/components/chip/chip.tsx
@@ -10,6 +10,10 @@ import {
import { cva, type VariantProps } from 'class-variance-authority'
import Link, { type LinkProps } from 'next/link'
import { cn } from '../../lib/cn'
+import {
+ MobileTouchTarget,
+ mobileTouchTargetClass,
+} from '../mobile-touch-target/mobile-touch-target'
import { OverflowText, overflowTextClipClass } from '../overflow-text/overflow-text'
import {
chipActiveSurfaceClass,
@@ -93,23 +97,7 @@ const chipVariants = cva(
type ChipIcon = ComponentType<{ className?: string }>
const CHIP_MOBILE_ICON_ONLY_CLASS =
- "max-md:relative max-md:size-[30px] max-md:shrink-0 max-md:justify-center max-md:gap-0 max-md:px-0 max-md:pointer-coarse:min-h-0 max-md:pointer-coarse:min-w-0 max-md:after:absolute max-md:after:top-1/2 max-md:after:left-1/2 max-md:after:size-11 max-md:after:-translate-x-1/2 max-md:after:-translate-y-1/2 max-md:after:content-['']"
-
-interface ChipTouchTargetProps {
- compact: boolean
- children: ReactNode
-}
-
-/** Reserves the expanded target without enlarging the chip's visible surface. */
-function ChipTouchTarget({ compact, children }: ChipTouchTargetProps) {
- return compact ? (
-
- {children}
-
- ) : (
- children
- )
-}
+ 'max-md:size-[30px] max-md:justify-center max-md:gap-0 max-md:px-0'
/**
* Variants a `Chip`/`ChipLink` may render. The `default` (bare) chip is implicit
@@ -131,6 +119,8 @@ interface ChipBaseProps extends Omit, 'variant
rightAdornment?: ReactNode
/** Below the desktop breakpoint, show a 30px icon face with a reserved 44px hit target. Requires a glyph and a visible or accessible label. */
mobileIconOnly?: boolean
+ /** Keeps the 30px face and label on mobile while reserving a 44px touch target. */
+ mobileCompact?: boolean
children?: ReactNode
}
@@ -191,6 +181,7 @@ const Chip = forwardRef(function Chip(
rightIcon,
rightAdornment,
mobileIconOnly,
+ mobileCompact,
children,
type,
...props
@@ -203,12 +194,17 @@ const Chip = forwardRef(function Chip(
((children != null && children !== false) || props['aria-label'] || props['aria-labelledby'])
)
return (
-
+
-
+
)
})
@@ -249,6 +245,7 @@ const ChipLink = forwardRef(function ChipLink(
rightIcon,
rightAdornment,
mobileIconOnly,
+ mobileCompact,
children,
...props
},
@@ -260,11 +257,16 @@ const ChipLink = forwardRef(function ChipLink(
((children != null && children !== false) || props['aria-label'] || props['aria-labelledby'])
)
return (
-
+
(function ChipLink(
{children}
-
+
)
})
diff --git a/packages/emcn/src/components/mobile-touch-target/mobile-touch-target.tsx b/packages/emcn/src/components/mobile-touch-target/mobile-touch-target.tsx
new file mode 100644
index 00000000000..fdab9f90e3e
--- /dev/null
+++ b/packages/emcn/src/components/mobile-touch-target/mobile-touch-target.tsx
@@ -0,0 +1,34 @@
+import type { ReactNode } from 'react'
+import { cn } from '../../lib/cn'
+
+/** Expands a compact control's hit area into the space reserved by MobileTouchTarget. */
+export const mobileTouchTargetClass =
+ "max-md:relative max-md:shrink-0 max-md:pointer-coarse:min-h-0 max-md:pointer-coarse:min-w-0 max-md:after:absolute max-md:after:top-1/2 max-md:after:left-1/2 max-md:after:h-11 max-md:after:w-full max-md:after:min-w-11 max-md:after:-translate-x-1/2 max-md:after:-translate-y-1/2 max-md:after:content-['']"
+
+interface MobileTouchTargetProps {
+ enabled: boolean
+ square?: boolean
+ fullWidth?: boolean
+ children: ReactNode
+}
+
+/** Reserves a 44px mobile target without enlarging the control's visible surface. */
+export function MobileTouchTarget({
+ enabled,
+ square,
+ fullWidth,
+ children,
+}: MobileTouchTargetProps) {
+ return enabled ? (
+
+ {children}
+
+ ) : (
+ children
+ )
+}