diff --git a/apps/desktop/e2e/chat-panel.spec.ts b/apps/desktop/e2e/chat-panel.spec.ts index 920be2ce387..69bc391dde7 100644 --- a/apps/desktop/e2e/chat-panel.spec.ts +++ b/apps/desktop/e2e/chat-panel.spec.ts @@ -289,22 +289,20 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c } await check('a viewport change during a drag clamps the committed display width', async () => { - await page.getByRole('button', { name: 'Resize container' }).click() - await expectWidth(520) + await expectWidth(698) const rect = await beginDrag() await page.mouse.move(rect.x + 20, rect.y + 100, { steps: 12 }) - await expectWidth(500) + await expectWidth(678) await shell.evaluate(({ BrowserWindow }) => - BrowserWindow.getAllWindows()[0].setContentSize(300, 400) + BrowserWindow.getAllWindows()[0].setContentSize(500, 400) ) - await expect.poll(() => page.evaluate(() => window.innerWidth)).toBe(600) + await expect.poll(() => page.evaluate(() => window.innerWidth)).toBe(1000) await page.mouse.up() - await expectWidth(480) + await expectWidth(520) await shell.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setContentSize(720, 400) ) - await expectWidth(500) - await page.getByRole('button', { name: 'Resize container' }).click() + await expectWidth(678) await dragTo(698) }) diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.tsx index 8c56ffe4d43..305ec3db460 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.tsx @@ -332,7 +332,7 @@ function OrganizationHomeContent({ ) const content = ( -
+
{hasChat ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx index 60102e514cb..c47b5077f43 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx @@ -12,7 +12,6 @@ import { ArrowLeft, PanelLeft } from '@sim/emcn/icons' import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' interface ChatPanelLayoutProps { - ref?: Ref children: ReactNode panel: ReactNode collapsed: boolean @@ -27,7 +26,6 @@ interface ChatPanelLayoutProps { /** Shared resize handle and collapse control for resources and Search results. */ export function ChatPanelLayout({ - ref, children, panel, collapsed, @@ -45,17 +43,14 @@ export function ChatPanelLayout({ }` return (
-
- {children} -
+
{children}
{!collapsed && ( -
+
- - {toggleLabel} - - {!collapsed && ( - Back to chat - )} + {toggleLabel} + {!collapsed && Back to chat} {!collapsed && ( - + )} {collapsed && activityCount > 0 && ( @@ -136,8 +127,8 @@ export function ChatPanelContent({ onKeyDownCapture={onInteraction} inert={collapsed} className={cn( - '@max-[960px]/chat-panel:absolute relative @max-[960px]/chat-panel:inset-0 z-10 flex h-full @max-[960px]/chat-panel:w-full! @max-[960px]/chat-panel:min-w-0! flex-col overflow-hidden border-[var(--border)] @max-[960px]/chat-panel:border-l-0 bg-[var(--bg)] @max-[960px]/chat-panel:transition-none transition-[width,min-width,border-width] duration-200 [transition-timing-function:cubic-bezier(0.25,0.1,0.25,1)] motion-reduce:transition-none', - collapsed ? '@max-[960px]/chat-panel:hidden w-0 min-w-0 border-l-0' : 'w-1/2 border-l', + 'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-200 [transition-timing-function:cubic-bezier(0.25,0.1,0.25,1)] motion-reduce:transition-none max-md:absolute max-md:inset-0 max-md:w-full! max-md:min-w-0! max-md:border-l-0 max-md:transition-none', + collapsed ? 'w-0 min-w-0 border-l-0 max-md:hidden' : 'w-1/2 border-l', '[--workspace-content-title-bar-inset:0px]', className )} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx index d19e43aafe3..0b93d1793e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx @@ -71,7 +71,6 @@ export function ChatResourcePanel({ ) return ( -
+
{showEmptyState && (
{ - preferredWidthRef.current = width - if (userId) useChatPanelStore.getState().setWidth(userId, desktopScopeId, width) + const preferredWidth = Math.max(MOTHERSHIP_WIDTH.MIN, width) + preferredWidthRef.current = preferredWidth + if (userId) useChatPanelStore.getState().setWidth(userId, desktopScopeId, preferredWidth) }, [userId, desktopScopeId] ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-panel.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-panel.ts index 979565dd6aa..c5ebf3def4e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-panel.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-panel.ts @@ -7,7 +7,6 @@ import { type SetStateAction, useCallback, useEffect, - useLayoutEffect, useMemo, useRef, useState, @@ -32,14 +31,11 @@ import { tableDetailParsers, tableDetailUrlKeys, } from '@/app/workspace/[workspaceId]/tables/[tableId]/search-params' - -/** Matches ChatPanelLayout's content-box container query. */ -const CHAT_PANEL_SPLIT_WIDTH = 960 +import { isMobileViewport, useIsMobile } from '@/hooks/use-is-mobile' /** URL selection and event attention are installed before the chat starts streaming. */ export function useResourcePanelController() { - const resourceLayoutRef = useRef(null) - const [isCompactLayout, setIsCompactLayout] = useState(null) + const isCompactLayout = useIsMobile() /** * URL is the single source of truth for the selected resource. `Home` renders * client-side, so nuqs reads `?resource=` from the URL on mount — the same @@ -108,7 +104,7 @@ export function useResourcePanelController() { selectionOwnedByUser: resourceSelectionOwnedByUserRef.current, }) - const keepChatVisible = isCompactLayout !== false && isResourceCollapsedRef.current + const keepChatVisible = isMobileViewport() && isResourceCollapsedRef.current if (presentation.revealPanel && !keepChatVisible) setResourceCollapsed(false) if (presentation.markActivity || keepChatVisible) { setResourceActivityIds((current) => new Set(current).add(resourceId)) @@ -131,19 +127,7 @@ export function useResourcePanelController() { } } - useLayoutEffect(() => { - const element = resourceLayoutRef.current - if (!element) return - setIsCompactLayout(Number.parseFloat(getComputedStyle(element).width) < CHAT_PANEL_SPLIT_WIDTH) - const observer = new ResizeObserver(([entry]) => { - setIsCompactLayout(entry.contentRect.width < CHAT_PANEL_SPLIT_WIDTH) - }) - observer.observe(element) - return () => observer.disconnect() - }, []) - return { - resourceLayoutRef, isCompactLayout, activeResourceParam, activeResourceParamRef, @@ -305,7 +289,8 @@ export function useChatResourcePanel( useEffect(() => { if ( - isCompactLayout !== false || + isCompactLayout || + isMobileViewport() || !(resources.length > 0 && isResourceCollapsedRef.current) || resourceCollapseOwnedByUser ) { @@ -336,7 +321,8 @@ export function useChatResourcePanel( initialScrollBlocked: resources.length > 0 && !resourceCollapseOwnedByUser && - (isCompactLayout === null || (isCompactLayout === false && isResourceCollapsed)), + !isCompactLayout && + isResourceCollapsed, desktopTabResourceOptions, mothershipRef, collapseResource, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index 23a81ebcddf..ce1c1e11ee6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -150,7 +150,7 @@ function ChatFilePreview({ file, onRemove }: ChatFilePreviewProps) { event.stopPropagation() onRemove(file.id) }} - className='pointer-coarse:static absolute top-0.5 right-0.5 pointer-coarse:size-11 pointer-coarse:shrink-0 @min-[960px]/workflow:pointer-fine:opacity-0 transition-opacity focus-visible:opacity-100 group-hover:opacity-100' + className='pointer-coarse:static absolute top-0.5 right-0.5 pointer-coarse:size-11 pointer-coarse:shrink-0 transition-opacity focus-visible:opacity-100 group-hover:opacity-100 md:pointer-fine:opacity-0' > { if (isFloatingLayout()) handleMouseDown(event) }} @@ -932,13 +932,13 @@ export function Chat() { {/* Start inputs button and output selector - with max-width to prevent overflow */}
e.stopPropagation()} > {shouldShowConfigureStartInputsButton && (
@@ -970,7 +970,7 @@ export function Chat() { aria-label='Chat actions' variant='ghost' iconPadding='md' - className='-m-1.5 @max-[960px]/workflow:m-0 @max-[960px]/workflow:size-11' + className='-m-1.5 max-md:m-0 max-md:size-11' onClick={(e) => e.stopPropagation()} > @@ -1013,7 +1013,7 @@ export function Chat() { aria-label='Close chat' variant='ghost' iconPadding='md' - className='-m-1.5 @max-[960px]/workflow:m-0 @max-[960px]/workflow:size-11' + className='-m-1.5 max-md:m-0 max-md:size-11' onClick={handleClose} > @@ -1096,7 +1096,7 @@ export function Chat() { )} {/* Input field with inline buttons */} -
+
{/* Buttons positioned absolutely on the right */} -
+
@@ -883,8 +882,9 @@ export const Panel = memo(function Panel() { - + ) }) @@ -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 + ) +}