diff --git a/.agents/skills/emcn-design-review/SKILL.md b/.agents/skills/emcn-design-review/SKILL.md index fef7bf5a1c3..4f0a9b6551e 100644 --- a/.agents/skills/emcn-design-review/SKILL.md +++ b/.agents/skills/emcn-design-review/SKILL.md @@ -62,6 +62,10 @@ Use `ChipConfirmModal` (title "Delete/Remove {ItemType}", `confirm={{ label, onC Default: `size-[14px]`. Color: `text-[var(--text-icon)]`. Scale: 14px > 16px > 12px > 20px. Use the `size-*` shorthand — flag `h-[Npx] w-[Npx]` and `h-N w-N` pairs as refactor targets. +## Mobile + +Check at 320px and 390px with touch, then desktop and fullscreen. Use `Chip`/`ChipLink`'s `mobileIconOnly` for familiar mobile toolbar actions, preserving accessible names; keep labels for ambiguous choices. Keep navigation, primary actions, and dismissal reachable without hover or dragging; aim for 44px touch targets with compact visible icons/button faces and 16px editable text. Match behavior to the actual container or viewport breakpoint. Contain horizontal scrolling to intentional tables/code, fit overlays to the dynamic viewport, and keep the composer/actions reachable with the keyboard open. Reuse EMCN tokens and brief motion with reduced-motion support; preserve desktop geometry. + ## Anti-patterns to flag - Raw ` - - - -

{file.name}

-
- - ))} + + + + +

{file.name}

+
+ + ))} + )} @@ -215,7 +240,8 @@ export const ChatInput: React.FC<{ onKeyDown={handleKeyDown} placeholder={isDragOver ? 'Drop files here...' : 'Enter a message...'} rows={1} - className='m-0 h-auto min-h-[24px] w-full resize-none overflow-y-auto overflow-x-hidden border-0 bg-transparent p-1 text-[15px] text-[var(--text-primary)] leading-[24px] caret-[var(--text-primary)] outline-hidden [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden' + aria-label='Message' + className='m-0 h-auto min-h-[24px] w-full resize-none overflow-y-auto overflow-x-hidden border-0 bg-transparent p-1 text-[var(--text-primary)] text-md leading-[24px] caret-[var(--text-primary)] outline-hidden [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 max-md:max-h-[min(200px,calc(var(--mobile-viewport-height,100dvh)*0.25))] md:text-base [&::-webkit-scrollbar]:hidden' />
diff --git a/apps/sim/app/(interfaces)/chat/components/input/public-chat-action-button.tsx b/apps/sim/app/(interfaces)/chat/components/input/public-chat-action-button.tsx index 838051a1817..77a5e6df1b4 100644 --- a/apps/sim/app/(interfaces)/chat/components/input/public-chat-action-button.tsx +++ b/apps/sim/app/(interfaces)/chat/components/input/public-chat-action-button.tsx @@ -12,6 +12,12 @@ interface PublicChatActionButtonProps /** Public chat's circular composer action, retaining its primary and quiet palettes. */ export const PublicChatActionButton = forwardRef( - (props, ref) => + {rootBreadcrumb?.label} + { + if (event.pointerType === 'mouse') openPopover() + }} + onPointerLeave={(event) => { + if (event.pointerType === 'mouse') scheduleClose() + }} > diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx index 19de857693b..85c62bca02e 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx @@ -147,21 +147,60 @@ export const ResourceOptions = memo(function ResourceOptions({ if (!hasContent) return null return ( -
-
+
+
{search && } {/* `ml-auto` moves to `trailing` when present so the menu cluster stays put and only the trailing action is pushed to the far edge. */} -
- {aside} -
- {filterTags?.map((tag) => ( - - {tag.label} - - ))} +
+ {aside && ( +
+ {aside} +
+ )} +
+ {Boolean(filterTags?.length) && ( +
+ {filterTags?.map((tag) => ( + + {tag.label} + + ))} +
+ )} {isToggleFilter && filter.mode === 'toggle' ? ( - + Filter ) : popoverFilter ? ( @@ -174,9 +213,9 @@ export const ResourceOptions = memo(function ResourceOptions({ } > -
+
- + Filter @@ -221,9 +260,9 @@ export const ResourceOptions = memo(function ResourceOptions({ const SearchSection = memo(function SearchSection({ search }: { search: SearchConfig }) { return ( -
+
{SEARCH_ICON} -
+
{search.tags?.map((tag, i) => ( search.onChange(''))} > @@ -289,7 +328,7 @@ export const SortDropdown = memo(function SortDropdown({ return ( - + Sort diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 95bf5999af8..f10f6165f50 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -15,6 +15,7 @@ import { import { Button, Checkbox, + Chip, cellIconNodeClass, chipActiveSurfaceClass, chipContentGap, @@ -24,7 +25,7 @@ import { cn, Loader, } from '@sim/emcn' -import { ChevronLeft, ChevronRight, Pin } from '@sim/emcn/icons' +import { ChevronLeft, ChevronRight, MoreHorizontal, Pin } from '@sim/emcn/icons' import { useVirtualizer } from '@tanstack/react-virtual' import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input' import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' @@ -155,8 +156,10 @@ function buildGridTemplateColumns(columns: ResourceColumn[], hasCheckbox: boolea const weights = columns.map( (col, colIdx) => (colIdx === 0 ? 2.5 : 1.0) * (col.widthMultiplier ?? 1) ) - const total = weights.reduce((s, w) => s + w, 0) - const tracks = columns.map((_, colIdx) => `minmax(0, ${(weights[colIdx] / total).toFixed(6)}fr)`) + const tracks = columns.map((_, colIdx) => { + const track = `minmax(0, ${weights[colIdx]}fr)` + return colIdx === 0 ? `var(--resource-table-name-width, ${track})` : track + }) return hasCheckbox ? `${CHECKBOX_COLUMN_WIDTH} ${tracks.join(' ')}` : tracks.join(' ') } @@ -186,7 +189,7 @@ interface ResourceProps { function ResourceRoot({ children, onContextMenu }: ResourceProps) { return (
{children} @@ -380,14 +383,23 @@ const ResourceTable = memo(function ResourceTable({
-
+
)} @@ -411,7 +424,7 @@ const ResourceTable = memo(function ResourceTable({
{col.header}
@@ -766,14 +779,19 @@ const DataRow = memo(function DataRow({ onCheckedChange={handleSelectRow} disabled={selectable.disabled} aria-label='Select row' + className='max-md:before:inset-[-15px]' onClick={handleSelectRowClick} />
)} - {columns.map((col) => { + {columns.map((col, index) => { const cell = row.cells[col.id] return ( -
+
+ {index === 0 && onRowContextMenu && ( +
+ { + event.stopPropagation() + onRowContextMenu(event, row.id) + }} + /> +
+ )}
) })} diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts index fdd2f9bf069..a3e5dabd418 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts @@ -1,3 +1,4 @@ export type { SidebarChromeState } from './sidebar-chrome-context' export { SidebarChromeProvider, useSidebarChrome } from './sidebar-chrome-context' export { WorkspaceChrome } from './workspace-chrome' +export { WorkspaceViewport } from './workspace-viewport' diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/sidebar-chrome-context.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/sidebar-chrome-context.tsx index cdb39e7de5d..ed287733b19 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/sidebar-chrome-context.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/sidebar-chrome-context.tsx @@ -16,6 +16,8 @@ export interface SidebarChromeState { * the chrome the card already provides (the title-bar lane, drag-resize). */ isPeeking: boolean + /** Toggles mobile navigation or the persisted desktop sidebar, as appropriate. */ + onToggle: () => void } const SidebarChromeContext = createContext(null) @@ -25,16 +27,20 @@ interface SidebarChromeProviderProps extends SidebarChromeState { } /** - * Hands the chrome's collapse and peek state to whichever sidebar it hosts. The + * Hands the chrome's state and toggle action to whichever sidebar it hosts. The * chrome owns that state; the sidebar is passed in as an element, so it cannot take * the values as props from a server layout — it reads them here instead. */ export function SidebarChromeProvider({ isCollapsed, isPeeking, + onToggle, children, }: SidebarChromeProviderProps) { - const value = useMemo(() => ({ isCollapsed, isPeeking }), [isCollapsed, isPeeking]) + const value = useMemo( + () => ({ isCollapsed, isPeeking, onToggle }), + [isCollapsed, isPeeking, onToggle] + ) return {children} } diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx index ec3c2f2a876..905645d932f 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx @@ -1,15 +1,24 @@ 'use client' -import { type ReactNode, useEffect, useLayoutEffect, useState } from 'react' +import { + type ReactNode, + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from 'react' import { applyDesktopTitleBarMode, type DesktopTitleBarMode } from '@sim/desktop-bridge' -import { cn } from '@sim/emcn' -import { ArrowLeft, ArrowRight, PanelLeft } from '@sim/emcn/icons' +import { Chip, cn } from '@sim/emcn' +import { ArrowLeft, ArrowRight, PanelLeft, X } from '@sim/emcn/icons' import { usePathname } from 'next/navigation' import { getDesktopBridge } from '@/lib/desktop' import { SidebarChromeProvider } from '@/app/workspace/[workspaceId]/components/workspace-chrome/sidebar-chrome-context' import { useSidebarPeek } from '@/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek' import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip' import { SIDEBAR_NO_MOTION_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +import { isMobileViewport, useIsMobile } from '@/hooks/use-is-mobile' import { useSidebarWidth } from '@/hooks/use-sidebar-width' import { useFullscreenOriginStore } from '@/stores/fullscreen-origin' import { useSearchModalStore } from '@/stores/modals/search/store' @@ -161,6 +170,26 @@ export function WorkspaceChrome({ }: WorkspaceChromeProps) { const pathname = usePathname() const isFullscreen = isFullscreenPath(pathname) + const isMobile = useIsMobile() + const navigationId = useId() + const navigationToggleRef = useRef(null) + const [mobileNavigationPath, setMobileNavigationPath] = useState(null) + const mobileNavigationOpen = isMobile && mobileNavigationPath === pathname && !isFullscreen + + if (mobileNavigationPath !== null && (mobileNavigationPath !== pathname || !isMobile)) { + setMobileNavigationPath(null) + } + + useEffect(() => { + if (!mobileNavigationOpen) return + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) return + setMobileNavigationPath(null) + navigationToggleRef.current?.focus() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [mobileNavigationOpen]) const setOrigin = useFullscreenOriginStore((s) => s.setOrigin) @@ -183,6 +212,16 @@ export function WorkspaceChrome({ const syncSidebarWidth = useSidebarStore((s) => s.syncWidth) const toggleSidebar = useSidebarStore((s) => s.toggleCollapsed) + const handleSidebarToggle = useCallback(() => { + if (isMobileViewport()) { + if (!isFullscreen) { + setMobileNavigationPath((current) => (current === pathname ? null : pathname)) + } + return + } + toggleSidebar() + }, [isFullscreen, pathname, toggleSidebar]) + /** * Single source of collapse for the whole chrome, driving the rail's structure, * labels, and width. The server renders from the `sidebar_collapsed` cookie @@ -198,7 +237,7 @@ export function WorkspaceChrome({ * desktop shell. The web app keeps a 51px icon rail (with its own hover flyouts), * and native fullscreen falls back to that same rail. */ - const peekEnabled = isCollapsed && !isFullscreen && titleBarMode === 'inset' + const peekEnabled = !isMobile && isCollapsed && !isFullscreen && titleBarMode === 'inset' const { isPeekActive, cardRef, triggerRef, onTriggerEnter, onTriggerLeave } = useSidebarPeek( peekEnabled, isSearchModalOpen @@ -226,7 +265,7 @@ export function WorkspaceChrome({ useEffect(() => { return getDesktopBridge()?.onCommand?.((command) => { if (command === 'toggle-sidebar') { - useSidebarStore.getState().toggleCollapsed() + handleSidebarToggle() return } // The shell's View > Search claims `Mod+K` before the renderer sees it, so @@ -236,7 +275,7 @@ export function WorkspaceChrome({ searchModal.setOpen(!searchModal.isOpen) } }) - }, []) + }, [handleSidebarToggle]) /** * A layout effect, not a passive one: the seed below arms the peek, and a passive @@ -276,9 +315,24 @@ export function WorkspaceChrome({ return (
+ {!isFullscreen && ( +
+ + {mobileNavigationOpen ? 'Close navigation' : 'Navigation'} + +
+ )}
{ + if ( + !mobileNavigationOpen || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return + } + const link = + event.target instanceof Element + ? event.target.closest('a[href]') + : null + if (link?.origin === window.location.origin && link.pathname === pathname) { + setMobileNavigationPath(null) + } + }} suppressHydrationWarning >
- + {sidebar}
(null) + useMobileViewport({ ref }) + return ( +
+ {children} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx index 75ec3d7b099..29594126924 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx @@ -1,5 +1,5 @@ import { Chip, cn } from '@sim/emcn' -import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut } from '@sim/emcn/icons' +import { ChevronLeft, ChevronRight, RefreshCw, ZoomIn, ZoomOut } from '@sim/emcn/icons' interface PreviewNavigationControls { current: number @@ -30,18 +30,16 @@ export function PreviewToolbar({ navigation, zoom, className }: PreviewToolbarPr return (
{navigation && }
-
{zoom && }
+
+ {zoom && } +
) } @@ -87,7 +85,7 @@ function PreviewZoomControls({ return ( <> {onReset && ( - + Reset )} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx index 80b86ad0d98..65bb669f989 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx @@ -32,7 +32,7 @@ const MERMAID = 'mermaid' /** Languages the Prism highlighter has registered (see {@link CodeBlockHighlight}). Every non-plain * value MUST have a grammar registered in {@link CodeBlockHighlight} — enforced by a unit test. */ -export const LANGUAGE_OPTIONS = [ +const LANGUAGE_OPTIONS = [ { value: PLAIN, label: 'Plain text' }, { value: 'bash', label: 'Bash' }, { value: 'c', label: 'C' }, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.ts index 5609f56922b..eb78ac5402f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.ts @@ -2,11 +2,13 @@ import { Extension } from '@tiptap/core' import type { Node as ProseMirrorNode } from '@tiptap/pm/model' import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state' import { Decoration, DecorationSet } from '@tiptap/pm/view' -import Prism, { type Token, type TokenStream } from 'prismjs' -import 'prismjs/components/prism-bash' -import 'prismjs/components/prism-css' +import type { Token, TokenStream } from 'prismjs' +import Prism from 'prismjs/components/prism-core' import 'prismjs/components/prism-markup' +import 'prismjs/components/prism-css' +import 'prismjs/components/prism-clike' import 'prismjs/components/prism-javascript' +import 'prismjs/components/prism-bash' import 'prismjs/components/prism-typescript' import 'prismjs/components/prism-yaml' import 'prismjs/components/prism-sql' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-languages.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-languages.test.ts deleted file mode 100644 index 7706c81e0ec..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-languages.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Guards against drift between the code-block language picker and the Prism grammars actually - * registered by CodeBlockHighlight: every selectable language must have a registered grammar, or it - * would silently fall back to no highlighting. - */ -import Prism from 'prismjs' -import { describe, expect, it } from 'vitest' -import { LANGUAGE_OPTIONS } from './code-block' -// Importing the highlighter registers all the prism-* grammars as a side effect. -import './code-highlight' - -describe('code-block languages', () => { - it('every selectable language has a registered Prism grammar', () => { - for (const { value } of LANGUAGE_OPTIONS) { - if (value === 'plain') continue - expect(Prism.languages[value], `no Prism grammar registered for "${value}"`).toBeDefined() - } - }) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/zoomable-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/zoomable-preview.tsx index afa9665bf77..b6ff75d78c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/zoomable-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/zoomable-preview.tsx @@ -1,9 +1,9 @@ 'use client' -import type { MouseEvent, ReactNode } from 'react' +import type { PointerEvent, ReactNode } from 'react' import { useCallback, useLayoutEffect, useRef, useState } from 'react' import { bindPreviewWheelZoom, cn } from '@sim/emcn' -import { PreviewToolbar } from './preview-toolbar' +import { PreviewToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar' const ZOOM_MIN = 0.25 const ZOOM_MAX = 4 @@ -78,7 +78,7 @@ export function ZoomablePreview({ const [contentSize, setContentSize] = useState({ width: 0, height: 0 }) const viewportRef = useRef(null) const contentRef = useRef(null) - const isDragging = useRef(false) + const activePointerIdRef = useRef(null) const dragStart = useRef({ x: 0, y: 0 }) const offsetAtDragStart = useRef({ x: 0, y: 0 }) const hasInteractedRef = useRef(false) @@ -214,18 +214,19 @@ export function ZoomablePreview({ setOffset({ x: 0, y: 0 }) }, [initialScale, resetKey]) - const handleMouseDown = (e: MouseEvent) => { - if (e.button !== 0) return + const handlePointerDown = (e: PointerEvent) => { + if (e.button !== 0 || activePointerIdRef.current !== null || !e.isPrimary) return hasInteractedRef.current = true - isDragging.current = true + activePointerIdRef.current = e.pointerId + e.currentTarget.setPointerCapture(e.pointerId) dragStart.current = { x: e.clientX, y: e.clientY } offsetAtDragStart.current = offsetRef.current if (viewportRef.current) viewportRef.current.style.cursor = 'grabbing' e.preventDefault() } - const handleMouseMove = (e: MouseEvent) => { - if (!isDragging.current) return + const handlePointerMove = (e: PointerEvent) => { + if (activePointerIdRef.current !== e.pointerId) return setOffset( clampOffset( containerSizeRef.current, @@ -239,8 +240,12 @@ export function ZoomablePreview({ ) } - const handleMouseUp = () => { - isDragging.current = false + const handlePointerEnd = (e: PointerEvent) => { + if (activePointerIdRef.current !== e.pointerId) return + activePointerIdRef.current = null + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId) + } if (viewportRef.current) viewportRef.current.style.cursor = 'grab' } @@ -260,16 +265,21 @@ export function ZoomablePreview({ ref={viewportRef} role='application' aria-label='Zoomable preview' - className='relative min-h-0 flex-1 cursor-grab overflow-hidden' - onMouseDown={handleMouseDown} - onMouseMove={handleMouseMove} - onMouseUp={handleMouseUp} - onMouseLeave={handleMouseUp} + className='relative min-h-0 flex-1 cursor-grab touch-pinch-zoom overflow-hidden' + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerEnd} + onPointerCancel={handlePointerEnd} + onLostPointerCapture={handlePointerEnd} >
children: ReactNode panel: ReactNode collapsed: boolean @@ -26,6 +27,7 @@ interface ChatPanelLayoutProps { /** Shared resize handle and collapse control for resources and Search results. */ export function ChatPanelLayout({ + ref, children, panel, collapsed, @@ -43,11 +45,17 @@ export function ChatPanelLayout({ }` return (
- {children} +
+ {children} +
{!collapsed && ( -
+
- )} + +
) }) @@ -146,18 +158,29 @@ export const AttachedFilesList = React.memo(function AttachedFilesList({ onFileClick, onRemoveFile, }: AttachedFilesListProps) { + const scrollRef = useRef(null) + const edges = useScrollEdges(scrollRef, { enabled: attachedFiles.length > 0 }) if (attachedFiles.length === 0) return null return ( -
- {attachedFiles.map((file) => ( - - ))} +
+
+ {attachedFiles.map((file) => ( + + ))} +
) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts index 12a4ecbd5da..f2378257bd6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts @@ -56,14 +56,14 @@ export interface PlusMenuHandle { } /** Shared draft typography for Build and Search composers. */ -export const PROMPT_TEXT_CLASSES = 'font-body text-[14px] leading-[24px] tracking-[-0.015em]' +export const PROMPT_TEXT_CLASSES = + 'font-body text-md leading-[24px] tracking-[-0.015em] md:pointer-fine:text-sm' /** * Box and typography shared by the textarea and its mirror overlay — both must * produce identical line wrapping so the overlay text sits exactly over the - * (transparent) textarea text. The scale is the chat input's native prompt - * scale (`text-[14px]`, `-0.015em` tracking); the task modal's body inherits it - * so the editor reads the same whether it's the chat input or inside the modal. + * (transparent) textarea text. The task modal inherits the same responsive + * prompt scale so its editor and the chat input wrap identically. */ export const PROMPT_FIELD_CLASSES = cn( 'm-0 box-border min-h-[24px] w-full [overflow-wrap:anywhere] border-0 bg-transparent', diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/input-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/input-toolbar.tsx index f637b3286e3..e6f5d35b98f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/input-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/input-toolbar.tsx @@ -43,7 +43,7 @@ export function InputToolbar({
void /** @@ -956,7 +957,13 @@ export function usePromptEditor({ } } - if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && policy?.onSubmit) { + if ( + e.key === 'Enter' && + !e.shiftKey && + !e.nativeEvent.isComposing && + !isTouchMobileViewport() && + policy?.onSubmit + ) { e.preventDefault() policy.onSubmit() return diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index 3db74309885..639435293a8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -44,6 +44,7 @@ import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId] import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' import { mentionifyIntegrations } from '@/blocks/integration-matcher' import { useChatInputFocus } from '@/hooks/use-chat-input-focus' +import { isMobileViewport } from '@/hooks/use-is-mobile' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useVoiceInput } from '@/hooks/use-voice-input' import { type DraftPayload, useMothershipDraftsStore } from '@/stores/mothership-drafts/store' @@ -202,7 +203,7 @@ const UserInputImpl = forwardRef(function UserI if (caretText !== null) { const textarea = textareaRef.current if (textarea) { - textarea.focus() + if (!isMobileViewport()) textarea.focus() textarea.setSelectionRange(caretText.length, caretText.length) } } @@ -591,10 +592,14 @@ const UserInputImpl = forwardRef(function UserI -
+
{showEmptyState && (
{/* Asymmetric padding biases the group up so the full cluster (heading + input + suggestions) sits at the optical center */} -
+

What should we get done{firstName ? `, ${firstName}` : ''}?

@@ -462,7 +461,7 @@ function HomeContent({ chatId, userName, userId }: HomeProps) { /> {/* Anchored out of flow so expanding/collapsing never shifts the centered input */} -
+
initialViewUserInputRef.current?.populatePrompt(prompt) @@ -503,7 +502,7 @@ function HomeContent({ chatId, userName, userId }: HomeProps) { draftScopeKey={draftScopeKey} animateInput={isInputEntering} onInputAnimationEnd={isInputEntering ? () => setIsInputEntering(false) : undefined} - initialScrollBlocked={resources.length > 0 && isResourceCollapsed} + initialScrollBlocked={panel.initialScrollBlocked} /> )}
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 45d678d0ff5..979565dd6aa 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,6 +7,7 @@ import { type SetStateAction, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -32,8 +33,13 @@ import { tableDetailUrlKeys, } from '@/app/workspace/[workspaceId]/tables/[tableId]/search-params' +/** Matches ChatPanelLayout's content-box container query. */ +const CHAT_PANEL_SPLIT_WIDTH = 960 + /** URL selection and event attention are installed before the chat starts streaming. */ export function useResourcePanelController() { + const resourceLayoutRef = useRef(null) + const [isCompactLayout, setIsCompactLayout] = useState(null) /** * 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 @@ -88,7 +94,7 @@ export function useResourcePanelController() { isResourceCollapsedRef.current = collapsed setIsResourceCollapsedState(collapsed) }, []) - const resourceCollapseOwnedByUserRef = useRef(false) + const [resourceCollapseOwnedByUser, setResourceCollapseOwnedByUser] = useState(false) const resourceSelectionOwnedByUserRef = useRef(false) function handleResourceEvent(resourceId: string, options?: ResourceEventOptions) { @@ -96,23 +102,25 @@ export function useResourcePanelController() { const presentation = resolveResourceEventPresentation({ activeResourceId, activationRequested: shouldActivateResourceEvent(activeResourceId, resourceId, options), - panelCollapseOwnedByUser: resourceCollapseOwnedByUserRef.current, + panelCollapseOwnedByUser: resourceCollapseOwnedByUser, panelCollapsed: isResourceCollapsedRef.current, resourceId, selectionOwnedByUser: resourceSelectionOwnedByUserRef.current, }) - if (presentation.revealPanel) setResourceCollapsed(false) - if (presentation.markActivity) { + const keepChatVisible = isCompactLayout !== false && isResourceCollapsedRef.current + if (presentation.revealPanel && !keepChatVisible) setResourceCollapsed(false) + if (presentation.markActivity || keepChatVisible) { setResourceActivityIds((current) => new Set(current).add(resourceId)) - return + if (presentation.markActivity) return + } else { + setResourceActivityIds((current) => { + if (!current.has(resourceId)) return current + const next = new Set(current) + next.delete(resourceId) + return next + }) } - setResourceActivityIds((current) => { - if (!current.has(resourceId)) return current - const next = new Set(current) - next.delete(resourceId) - return next - }) if (presentation.activateResource && options?.tableViewId) { /** A live view request replaces the host URL's previous table selection. */ void setTableParams({ view: options.tableViewId, sort: null, dir: null }) @@ -123,7 +131,20 @@ 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, activeResourceState, @@ -135,7 +156,8 @@ export function useResourcePanelController() { resourceActivityIds, setResourceActivityIds, isResourceCollapsedRef, - resourceCollapseOwnedByUserRef, + resourceCollapseOwnedByUser, + setResourceCollapseOwnedByUser, resourceSelectionOwnedByUserRef, effectiveActiveResourceIdRef, onResourceEvent: handleResourceEvent, @@ -167,6 +189,7 @@ export function useChatResourcePanel( setActiveResourceId, } = chat const { + isCompactLayout, activeResourceParam, activeResourceParamRef, isResourceCollapsed, @@ -174,7 +197,8 @@ export function useChatResourcePanel( setSkipResourceTransition, setResourceActivityIds, isResourceCollapsedRef, - resourceCollapseOwnedByUserRef, + resourceCollapseOwnedByUser, + setResourceCollapseOwnedByUser, resourceSelectionOwnedByUserRef, effectiveActiveResourceIdRef, onResourceEvent: handleResourceEvent, @@ -185,10 +209,10 @@ export function useChatResourcePanel( const resourceAttentionChatIdRef = useRef(resolvedChatId) const collapseResource = useCallback(() => { - resourceCollapseOwnedByUserRef.current = true + setResourceCollapseOwnedByUser(true) resourceSelectionOwnedByUserRef.current = true setResourceCollapsed(true) - }, [setResourceCollapsed]) + }, [setResourceCollapsed, setResourceCollapseOwnedByUser]) const clearResourceActivity = useCallback((resourceId: string) => { setResourceActivityIds((current) => { @@ -200,7 +224,7 @@ export function useChatResourcePanel( }, []) const expandResource = () => { - resourceCollapseOwnedByUserRef.current = false + setResourceCollapseOwnedByUser(false) resourceSelectionOwnedByUserRef.current = true const activeResourceId = effectiveActiveResourceIdRef.current if (activeResourceId) clearResourceActivity(activeResourceId) @@ -232,13 +256,13 @@ export function useChatResourcePanel( const addResourceFromUser = useCallback( (resource: MothershipResource) => { - resourceCollapseOwnedByUserRef.current = false + setResourceCollapseOwnedByUser(false) resourceSelectionOwnedByUserRef.current = true addResource(resource) selectResourceFromUser(getChatResourceSelectionId(resource)) setResourceCollapsed(false) }, - [addResource, selectResourceFromUser, setResourceCollapsed] + [addResource, selectResourceFromUser, setResourceCollapsed, setResourceCollapseOwnedByUser] ) const handleResourceResizePointerDown = useCallback( @@ -273,16 +297,17 @@ export function useChatResourcePanel( setResourceCollapsed(true) } if (!resolvedChatId || (previousChatId && previousChatId !== resolvedChatId)) { - resourceCollapseOwnedByUserRef.current = false + setResourceCollapseOwnedByUser(false) resourceSelectionOwnedByUserRef.current = false setResourceActivityIds(new Set()) } - }, [resolvedChatId, setResourceCollapsed]) + }, [resolvedChatId, setResourceCollapsed, setResourceCollapseOwnedByUser]) useEffect(() => { if ( + isCompactLayout !== false || !(resources.length > 0 && isResourceCollapsedRef.current) || - resourceCollapseOwnedByUserRef.current + resourceCollapseOwnedByUser ) { return } @@ -290,7 +315,7 @@ export function useChatResourcePanel( setSkipResourceTransition(true) const id = requestAnimationFrame(() => setSkipResourceTransition(false)) return () => cancelAnimationFrame(id) - }, [resources, setResourceCollapsed]) + }, [isCompactLayout, resources, resourceCollapseOwnedByUser, setResourceCollapsed]) useEffect(() => { if (resources.length === 0 && !isResourceCollapsedRef.current) { @@ -308,6 +333,10 @@ export function useChatResourcePanel( return { ...controller, + initialScrollBlocked: + resources.length > 0 && + !resourceCollapseOwnedByUser && + (isCompactLayout === null || (isCompactLayout === false && isResourceCollapsed)), desktopTabResourceOptions, mothershipRef, collapseResource, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 9de2b6c4cb5..a36b2215fb3 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -1310,7 +1310,7 @@ export function Document({ /> 0 && !isConnectorDocument ? handleBulkEnable : undefined} onDisable={enabledCount > 0 && !isConnectorDocument ? handleBulkDisable : undefined} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index fc1bd021394..1650f0fbde9 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -1353,7 +1353,9 @@ export function KnowledgeBase({ }} overlay={ 1 ? 'bottom-[72px]' : undefined} + className={ + totalPages > 1 ? 'bottom-[max(72px,env(safe-area-inset-bottom))]' : undefined + } selectedCount={selectedDocuments.size} onEnable={disabledCount > 0 ? handleBulkEnable : undefined} onDisable={enabledCount > 0 ? handleBulkDisable : undefined} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx index 01dbfaead48..20d1c1afe2f 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx @@ -51,7 +51,10 @@ export function ActionBar({ animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 10 }} transition={{ duration: 0.2 }} - className={cn('-translate-x-1/2 fixed bottom-6 left-1/2 z-[var(--z-dropdown)]', className)} + className={cn( + '-translate-x-1/2 fixed bottom-[max(1.5rem,env(safe-area-inset-bottom))] left-1/2 z-[var(--z-dropdown)] max-md:max-w-[calc(100%-2rem)]', + className + )} >
@@ -82,7 +85,7 @@ export function ActionBar({ )} -
+
{showEnableButton && ( diff --git a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx index f42e3b26788..b3f50d1d568 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx @@ -73,6 +73,7 @@ vi.mock('@/app/workspace/[workspaceId]/components/session-expired', () => ({ vi.mock('@/app/workspace/[workspaceId]/components/workspace-chrome', () => ({ WorkspaceChrome: mockWorkspaceChrome, + WorkspaceViewport: ({ children }: { children: ReactNode }) => children, })) vi.mock('@/lib/desktop/executor/presence', () => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index 5ee72f7ed95..fc990657633 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -16,7 +16,10 @@ import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner' import { SessionExpired } from '@/app/workspace/[workspaceId]/components/session-expired' import { WorkspaceAccessDenied } from '@/app/workspace/[workspaceId]/components/workspace-access-denied' -import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome' +import { + WorkspaceChrome, + WorkspaceViewport, +} from '@/app/workspace/[workspaceId]/components/workspace-chrome' import { prefetchWorkspaceForkAvailability, prefetchWorkspaceHostContext, @@ -117,7 +120,7 @@ export default async function WorkspaceLayout({ -
+ @@ -139,7 +142,7 @@ export default async function WorkspaceLayout({ -
+
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index ff435d7d94b..18cf3eca63d 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -36,6 +36,7 @@ import type { WorkflowLogSummary, } from '@/lib/api/contracts/logs' import { dollarsToCredits } from '@/lib/billing/credits/conversion' +import { getContextMenuPosition } from '@/lib/core/utils/context-menu' import { formatDateShort } from '@/lib/core/utils/date-display' import { getEndDateFromTimeRange, @@ -537,7 +538,7 @@ export default function Logs() { (e: React.MouseEvent, rowId: string) => { e.preventDefault() const log = logs.find((l) => l.id === rowId) ?? null - setContextMenuPosition({ x: e.clientX, y: e.clientY }) + setContextMenuPosition(getContextMenuPosition(e)) setContextMenuLog(log) setContextMenuOpen(true) }, diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx b/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx index 5b79191fb31..a0eb4efd301 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx @@ -7,7 +7,9 @@ import { apiClientRequestMockFns, } from '@sim/testing/mocks/api-client-request.mock' import { emcnIconsMock } from '@sim/testing/mocks/emcn-icons.mock' +import { integrationsAvailabilityMock } from '@sim/testing/mocks/integrations-availability.mock' import { nextNavigationMock, nextNavigationMockFns } from '@sim/testing/mocks/next-navigation.mock' +import { oauthUtilsMock, oauthUtilsMockFns } from '@sim/testing/mocks/oauth-utils.mock' import { dehydrate, hydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query' import { renderToString } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -23,6 +25,8 @@ vi.mock('@/ee/access-requests/lib/application/requests', () => ({ discoverAccessRequests: { execute: mocks.discovery }, })) vi.mock('@/lib/api/client/request', () => apiClientRequestMock) +vi.mock('@/lib/integrations/availability.server', () => integrationsAvailabilityMock) +vi.mock('@/lib/oauth/utils', () => oauthUtilsMock) vi.mock('next/navigation', () => nextNavigationMock) vi.mock('@sim/emcn', () => ({ cn: (...values: string[]) => values.join(' '), @@ -45,6 +49,11 @@ vi.mock('@/ee/access-requests/components/request-access-action', () => ({ import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { prefetchWorkspaceAccess } from '@/app/workspace/[workspaceId]/prefetch-access' import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' +import { + accessRequestKeys, + workspaceFeatureDiscoveryQuery, +} from '@/hooks/queries/utils/access-request-keys' +import { permissionGroupKeys } from '@/hooks/queries/utils/permission-group-keys' nextNavigationMockFns.mockUseParams.mockReturnValue({ workspaceId: 'workspace' }) @@ -83,6 +92,7 @@ describe('workspace access hydration', () => { nextNavigationMockFns.mockUseParams.mockReturnValue({ workspaceId: 'workspace' }) mocks.policy.mockResolvedValue(policy) mocks.discovery.mockResolvedValue(discovery) + oauthUtilsMockFns.mockGetAllOAuthServices.mockReturnValue([]) mockRequestJson.mockImplementation(() => new Promise(() => {})) server = new QueryClient({ defaultOptions: { queries: { retry: false } } }) client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) @@ -113,7 +123,7 @@ describe('workspace access hydration', () => { if (failure === 'rejected') mocks.policy.mockRejectedValue(new Error('unavailable')) else mocks.policy.mockResolvedValue({ config: null }) await prefetch() - expect(dehydrate(server).queries).toHaveLength(0) + expect(client.getQueryData(permissionGroupKeys.userConfig('workspace'))).toBeUndefined() expect(mocks.discovery).not.toHaveBeenCalled() expect(renderToString(tree())).toContain('Checking access') expect(renderToString(tree())).not.toContain('Workspace chat') @@ -123,7 +133,9 @@ describe('workspace access hydration', () => { restrict() mocks.discovery.mockRejectedValue(new Error('unavailable')) await prefetch() - expect(dehydrate(server).queries).toHaveLength(1) + expect( + client.getQueryData(accessRequestKeys.discovery(workspaceFeatureDiscoveryQuery('workspace'))) + ).toBeUndefined() expect(renderToString(tree())).toContain('Checking access') expect(renderToString(tree())).not.toContain('Workspace chat') }) diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts b/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts index 6fb0e5162aa..27f45dd0f91 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts @@ -1,10 +1,12 @@ import type { SessionPrincipal } from '@sim/auth/principal' import type { QueryClient } from '@tanstack/react-query' import { discoverAccessRequestsContract } from '@/lib/api/contracts/access-requests' +import { getAllowedIntegrationsContract } from '@/lib/api/contracts/common' import { type UserPermissionConfig, userPermissionConfigSchema, } from '@/lib/api/contracts/permission-groups' +import { readIntegrationAvailability } from '@/lib/integrations/application/read-availability' import { readUserPermissionConfig } from '@/lib/permission-groups/application/read-user-config' import { PLATFORM_FEATURES } from '@/lib/permission-groups/features' import { @@ -12,6 +14,10 @@ import { accessRequestKeys, workspaceFeatureDiscoveryQuery, } from '@/hooks/queries/utils/access-request-keys' +import { + INTEGRATION_AVAILABILITY_STALE_TIME, + integrationAvailabilityKeys, +} from '@/hooks/queries/utils/integration-availability-keys' import { PERMISSION_GROUPS_STALE_TIME, permissionGroupKeys, @@ -24,14 +30,24 @@ export async function prefetchWorkspaceAccess( principal: SessionPrincipal ): Promise { const queryKey = permissionGroupKeys.userConfig(workspaceId) - await queryClient.prefetchQuery({ - queryKey, - queryFn: async () => - userPermissionConfigSchema.parse( - await readUserPermissionConfig.execute({ principal, input: { workspaceId } }) - ), - staleTime: PERMISSION_GROUPS_STALE_TIME, - }) + await Promise.all([ + queryClient.prefetchQuery({ + queryKey, + queryFn: async () => + userPermissionConfigSchema.parse( + await readUserPermissionConfig.execute({ principal, input: { workspaceId } }) + ), + staleTime: PERMISSION_GROUPS_STALE_TIME, + }), + queryClient.prefetchQuery({ + queryKey: integrationAvailabilityKeys.environments(), + queryFn: async () => + getAllowedIntegrationsContract.response.schema.parse( + await readIntegrationAvailability.execute({ principal, input: undefined }) + ), + staleTime: INTEGRATION_AVAILABILITY_STALE_TIME, + }), + ]) const policy = queryClient.getQueryData(queryKey) if ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx index 9730ea02a49..869068faa5a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx @@ -1,7 +1,7 @@ 'use client' -import { type ReactNode, useState } from 'react' -import { cn } from '@sim/emcn' +import { type ReactNode, useRef, useState } from 'react' +import { cn, scrollFadeAttributes, scrollFadeXClass, useScrollEdges } from '@sim/emcn' import { ChevronDown } from '@sim/emcn/icons' import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' @@ -165,37 +165,53 @@ export function ActivityLog({ emptyState, footer, }: ActivityLogProps) { + const scrollRef = useRef(null) + const edges = useScrollEdges(scrollRef, { axis: 'x' }) const hasTrailingColumn = entries.some((entry) => entry.trailing != null) return ( -
-
- Timestamp - {eventLabel} - {descriptionLabel} - Actor - {/* Row actions carry no header, but the column must still be reserved +
+
+
+
+ Timestamp + + {eventLabel} + + {descriptionLabel} + Actor + {/* Row actions carry no header, but the column must still be reserved here or every label above would sit left of the data below it. */} - {hasTrailingColumn && ( - - )} -
+ {hasTrailingColumn && ( + + )} +
- {entries.length === 0 ? ( - emptyState - ) : ( -
- {entries.map((entry) => ( - - ))} - {footer} + {entries.length > 0 && ( +
+ {entries.map((entry) => ( + + ))} +
+ )}
- )} +
+ {entries.length > 0 && footer &&
{footer}
} + {entries.length === 0 && emptyState}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/appearance-theme-select.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/appearance-theme-select.tsx index aab315bc6f9..665906f3281 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/appearance-theme-select.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/appearance-theme-select.tsx @@ -24,7 +24,7 @@ export function AppearanceThemeSelect({ ariaLabel, }: AppearanceThemeSelectProps) { return ( -
+
+
-
+
-
+
-
+
+
@@ -261,14 +261,14 @@ function ColumnConfigBody({ return (
-
+

Configure column

- {readOnly ? ( - @@ -400,7 +410,13 @@ function ColumnConfigBody({ {readOnlyReason && {readOnlyReason}} ) : ( - )} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.tsx index d157ea04665..a29bcbe8101 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.tsx @@ -81,7 +81,7 @@ export const ColumnsMenu = memo(function ColumnsMenu({ {/* `active` alone signals that something is hidden — the label stays fixed so the bar doesn't reflow as columns are toggled. */} - 0} leftIcon={Columns3}> + 0} leftIcon={Columns3} mobileIconOnly> Columns diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx index f411281d21e..6e4fd335ca6 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx @@ -232,14 +232,14 @@ export function EnrichmentConfig({ return (
-
+
- -
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx index 0aebd34cb4c..66faef4f958 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx @@ -31,7 +31,7 @@ export function EnrichmentsSidebar({ open, ...rest }: EnrichmentsSidebarProps) { role='dialog' aria-label='Enrichments' className={cn( - 'absolute top-0 right-0 bottom-0 z-[var(--z-modal)] flex w-[400px] flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--bg)] transition-transform duration-200 ease-out', + 'absolute top-0 right-0 bottom-0 z-[var(--z-modal)] flex w-full flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--bg)] transition-transform duration-200 ease-out motion-reduce:transition-none md:w-[400px]', open ? 'translate-x-0 shadow-overlay' : 'translate-x-full' )} > @@ -71,14 +71,14 @@ function EnrichmentsSidebarBody({ if (editGroup && !editEnrichment) { return (
-
+

Enrichment

- + {isStopping ? 'Stopping…' : 'Stop all'}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx index b04257ae755..a8cfde1a740 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx @@ -85,7 +85,7 @@ export function TableActionBar({ exit={{ opacity: 0, y: 10 }} transition={{ duration: 0.2 }} className={cn( - '-translate-x-1/2 pointer-events-none absolute bottom-6 left-1/2 z-50', + '-translate-x-1/2 pointer-events-none absolute bottom-[max(1.5rem,env(safe-area-inset-bottom))] left-1/2 z-50 max-md:max-w-[calc(100%-2rem)]', className )} > @@ -96,7 +96,7 @@ export function TableActionBar({ : `Selected ${selectedCellCount} workflow cells`} -
+
{showPlay && ( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx index bab40b19ddd..b3574b54337 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx @@ -135,16 +135,20 @@ export function ExpandedCellPopover({ if (!expandedCell || !target || !rect) return null - const width = Math.max(rect.width, EXPANDED_CELL_MIN_WIDTH) + const VIEWPORT_PAD = 8 + const width = Math.min( + Math.max(rect.width, EXPANDED_CELL_MIN_WIDTH), + window.innerWidth - VIEWPORT_PAD * 2 + ) // Clamp to viewport. Prefer anchoring at the cell's left edge; if the popover // would overflow right, align its right edge with the cell's right edge // (mirroring Radix/menu flip behavior). Same idea for bottom-of-viewport. - const VIEWPORT_PAD = 8 const cellRight = rect.left + rect.width const overflowsRight = rect.left + width > window.innerWidth - VIEWPORT_PAD - const left = overflowsRight - ? Math.max(VIEWPORT_PAD, cellRight - width) - : Math.max(VIEWPORT_PAD, rect.left) + const left = Math.min( + window.innerWidth - width - VIEWPORT_PAD, + Math.max(VIEWPORT_PAD, overflowsRight ? cellRight - width : rect.left) + ) const overflowsBottom = rect.top + EXPANDED_CELL_HEIGHT > window.innerHeight - VIEWPORT_PAD const top = overflowsBottom ? Math.max(VIEWPORT_PAD, window.innerHeight - EXPANDED_CELL_HEIGHT - VIEWPORT_PAD) @@ -271,7 +275,7 @@ function ExpandedCellEditor({ }} onKeyDown={handleTextareaKeyDown} readOnly={Boolean(saveBlockedReason)} - className='min-h-0 flex-1 resize-none bg-transparent px-2.5 py-2 font-sans text-[var(--text-primary)] text-small outline-hidden placeholder:text-[var(--text-muted)]' + className='min-h-0 flex-1 resize-none bg-transparent px-2.5 py-2 font-sans text-[var(--text-primary)] text-small outline-hidden placeholder:text-[var(--text-muted)] max-md:pointer-coarse:text-md' spellCheck={false} autoCorrect='off' /> diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx index 0c603a6e1dc..6b23deebb3a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx @@ -198,6 +198,7 @@ export const DataRow = React.memo(function DataRow({ lastPinnedColKey, findMatchColumns, }: DataRowProps) { + const touchedCellRef = React.useRef<{ columnKey: string; selected: boolean } | null>(null) const sel = normalizedSelection /** * Per-row "Waiting on …" labels keyed by group id. A group has labels iff @@ -349,18 +350,36 @@ export const DataRow = React.memo(function DataRow({ isPinnedSeparator && '[box-shadow:2px_0_0_0_var(--border)]' )} style={isPinnedCell ? { position: 'sticky', left: pinnedLeft } : undefined} + onPointerDown={(event) => { + touchedCellRef.current = + event.pointerType === 'touch' + ? { columnKey: column.key, selected: isAnchor && !isMultiCell } + : null + }} + onPointerCancel={() => { + touchedCellRef.current = null + }} onMouseDown={(e) => { if (e.button !== 0 || isEditing) return onCellMouseDown(rowIndex, colIndex, e.shiftKey) }} onMouseEnter={() => onCellMouseEnter(rowIndex, colIndex)} - onClick={(e) => - onClick(row.id, column.key, { - toggleBoolean: - !e.shiftKey && - Boolean((e.target as HTMLElement).closest('[data-boolean-cell-toggle]')), - }) - } + onClick={(e) => { + const touchedCell = touchedCellRef.current + touchedCellRef.current = null + const toggleBoolean = + !e.shiftKey && + Boolean((e.target as HTMLElement).closest('[data-boolean-cell-toggle]')) + onClick(row.id, column.key, { toggleBoolean }) + if ( + touchedCell?.columnKey === column.key && + touchedCell.selected && + !isEditing && + !toggleBoolean + ) { + onDoubleClick(row.id, column.key, column.key) + } + }} onDoubleClick={() => onDoubleClick(row.id, column.key, column.key)} > {/* No z-index on purpose: with `auto` it paints in DOM order, so it diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx index 5fb467f4d3f..e1612867623 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx @@ -338,7 +338,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
- -
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index a1786d2dc65..7703b71ad3b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -1573,6 +1573,7 @@ export function Table({ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx index 4e1fea54a41..c5db1f7877d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx @@ -22,11 +22,6 @@ vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components ChatMessage: () => null, OutputSelect: () => null, })) -vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/hooks/float', () => ({ - useFloatDrag: () => ({}), - useFloatBoundarySync: () => {}, - useFloatResize: () => ({}), -})) let root: Root let container: HTMLDivElement @@ -34,6 +29,11 @@ let container: HTMLDivElement beforeEach(() => { vi.useFakeTimers() vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.stubGlobal('matchMedia', () => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })) Object.defineProperty(HTMLElement.prototype, 'scrollTo', { configurable: true, value: vi.fn() }) vi.stubGlobal( 'ResizeObserver', 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 e4e41f81a14..23a81ebcddf 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 @@ -2,7 +2,6 @@ import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { - Badge, Button, ComposerActionButton, cn, @@ -12,8 +11,11 @@ import { PopoverItem, PopoverScrollArea, PopoverTrigger, + scrollFadeAttributes, + scrollFadeClass, Tooltip, Trash, + useScrollEdges, } from '@sim/emcn' import { ArrowUp, CircleAlert, Download, MoreVertical, Paperclip, Square, X } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' @@ -47,6 +49,7 @@ import { import { useFloatBoundarySync, useFloatDrag, + useFloatLayout, useFloatResize, } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/float' import { @@ -59,6 +62,7 @@ import type { WorkflowAttachmentInput, } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload' import type { BlockLog, ExecutionResult } from '@/executor/types' +import { isMobileViewport, useIsMobile } from '@/hooks/use-is-mobile' import { useChatStore } from '@/stores/chat/store' import { getChatPosition } from '@/stores/chat/utils' import { useIsCurrentWorkflowExecuting } from '@/stores/execution' @@ -119,17 +123,21 @@ function ChatFilePreview({ file, onRemove }: ChatFilePreviewProps) { return (
{previewUrl ? ( - {file.name} + {file.name} ) : (
-
{file.name}
+
{file.name}
{formatFileSize(file.size)}
)} @@ -142,9 +150,17 @@ function ChatFilePreview({ file, onRemove }: ChatFilePreviewProps) { event.stopPropagation() onRemove(file.id) }} - className='absolute top-0.5 right-0.5 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 @min-[960px]/workflow:pointer-fine:opacity-0 transition-opacity focus-visible:opacity-100 group-hover:opacity-100' > - + + +
) @@ -221,6 +237,7 @@ interface StartInputFormatField { * position across sessions using the floating chat store. */ export function Chat() { + const isMobile = useIsMobile() const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) const blocks = useWorkflowStore((state) => state.blocks) const triggerWorkflowUpdate = useWorkflowStore((state) => state.triggerUpdate) @@ -287,6 +304,7 @@ export function Chat() { const [moreMenuOpen, setMoreMenuOpen] = useState(false) const inputRef = useRef(null) + const attachmentsRef = useRef(null) const timeoutRef = useRef(null) const streamReaderRef = useRef | null>(null) const preventZoomRef = usePreventZoom() @@ -305,6 +323,9 @@ export function Chat() { handleDragLeave, handleDrop, } = useChatFileUpload() + const attachmentEdges = useScrollEdges(attachmentsRef, { + enabled: isChatOpen && chatFiles.length > 0, + }) /** * Resolves the unified start block for chat execution, if available. @@ -371,24 +392,27 @@ export function Chat() { const shouldShowConfigureStartInputsButton = Boolean(startBlockId) && missingStartReservedFields.length > 0 - const actualPosition = useMemo( - () => getChatPosition(chatPosition, chatWidth, chatHeight), - [chatPosition, chatWidth, chatHeight] - ) + const { isFloatingLayout, updatePosition, updateDimensions } = useFloatLayout({ + ref: preventZoomRef, + onPositionChange: setChatPosition, + onDimensionsChange: setChatDimensions, + }) + + const actualPosition = getChatPosition(chatPosition, chatWidth, chatHeight) const { handleMouseDown } = useFloatDrag({ position: actualPosition, width: chatWidth, height: chatHeight, - onPositionChange: setChatPosition, + onPositionChange: updatePosition, }) useFloatBoundarySync({ - isOpen: isChatOpen, + isOpen: isChatOpen && !isMobile, position: actualPosition, width: chatWidth, height: chatHeight, - onPositionChange: setChatPosition, + onPositionChange: updatePosition, }) const { @@ -400,8 +424,8 @@ export function Chat() { position: actualPosition, width: chatWidth, height: chatHeight, - onPositionChange: setChatPosition, - onDimensionsChange: setChatDimensions, + onPositionChange: updatePosition, + onDimensionsChange: updateDimensions, }) const workflowMessages = useMemo(() => { @@ -456,7 +480,7 @@ export function Chat() { timeoutRef.current && clearTimeout(timeoutRef.current) timeoutRef.current = setTimeout(() => { - if (inputRef.current && document.contains(inputRef.current)) { + if (!isMobileViewport() && inputRef.current && document.contains(inputRef.current)) { inputRef.current.focus({ preventScroll: true }) } }, delay) @@ -880,7 +904,7 @@ export function Chat() { ref={preventZoomRef} role='dialog' aria-label='Chat' - className='fixed z-30 flex flex-col overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--surface-1)] px-2.5 pt-0.5 pb-2' + className='fixed @max-[960px]/workflow:absolute @max-[960px]/workflow:inset-0! z-30 flex @max-[960px]/workflow:size-auto! @max-[960px]/workflow:cursor-default! flex-col overflow-hidden @max-[960px]/workflow:rounded-none rounded-lg border @max-[960px]/workflow:border-0 border-[var(--border)] bg-[var(--surface-1)] px-2.5 pt-0.5 @max-[960px]/workflow:pb-[max(8px,env(safe-area-inset-bottom))] pb-2 motion-reduce:transition-none' style={{ left: `${actualPosition.x}px`, top: `${actualPosition.y}px`, @@ -888,27 +912,33 @@ export function Chat() { height: `${chatHeight}px`, cursor: resizeCursor || undefined, }} - onMouseMove={handleResizeMouseMove} + onMouseMove={(event) => { + if (isFloatingLayout()) handleResizeMouseMove(event) + }} onMouseLeave={handleResizeMouseLeave} - onMouseDown={handleResizeMouseDown} + onMouseDown={(event) => { + if (isFloatingLayout()) handleResizeMouseDown(event) + }} > {/* Header with drag handle */}
{ + if (isFloatingLayout()) handleMouseDown(event) + }} > Chat {/* Start inputs button and output selector - with max-width to prevent overflow */}
e.stopPropagation()} > {shouldShowConfigureStartInputsButton && (
@@ -939,7 +970,7 @@ export function Chat() { aria-label='Chat actions' variant='ghost' iconPadding='md' - className='-m-1.5' + className='-m-1.5 @max-[960px]/workflow:m-0 @max-[960px]/workflow:size-11' onClick={(e) => e.stopPropagation()} > @@ -982,7 +1013,7 @@ export function Chat() { aria-label='Close chat' variant='ghost' iconPadding='md' - className='-m-1.5' + className='-m-1.5 @max-[960px]/workflow:m-0 @max-[960px]/workflow:size-11' onClick={handleClose} > @@ -991,7 +1022,7 @@ export function Chat() {
{/* Chat content */} -
+
{/* Messages */}
{workflowMessages.length === 0 ? ( @@ -1048,15 +1079,24 @@ export function Chat() { > {/* File thumbnails */} {chatFiles.length > 0 && ( -
- {chatFiles.map((file) => ( - - ))} +
+
+ {chatFiles.map((file) => ( + + ))} +
)} {/* Input field with inline buttons */} -
+
{/* Buttons positioned absolutely on the right */} -
+
- = MAX_CHAT_FILES + } onClick={() => document.getElementById('floating-chat-file-input')?.click()} className={cn( - 'cursor-pointer rounded-md border-0! bg-transparent! p-[0px]', + '@max-[960px]/workflow:size-11 size-[14px] cursor-pointer rounded-md border-0! bg-transparent! p-0', (!activeWorkflowId || isExecuting || chatFiles.length >= MAX_CHAT_FILES) && 'cursor-not-allowed opacity-50' )} > - - + + Attach file @@ -1093,6 +1139,7 @@ export function Chat() { aria-label='Stop generation' onClick={handleStopStreaming} size='sm' + className='@max-[960px]/workflow:size-11' > @@ -1101,6 +1148,7 @@ export function Chat() { aria-label='Send message' onClick={handleSendMessage} size='sm' + className='@max-[960px]/workflow:size-11' disabled={ (!chatMessage.trim() && chatFiles.length === 0) || !activeWorkflowId || diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts index fbecf8be6f7..790ebc5a796 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts @@ -7,7 +7,9 @@ import { usePanelStore } from '@/stores/panel' * floored at the minimum so a narrow viewport can never invert the clamp * and force the panel below {@link PANEL_WIDTH.MIN}. */ -function computePanelWidth(ev: PointerEvent): number { +function computePanelWidth(ev: PointerEvent): number | null { + const panel = getPanelContainer() + if (panel && getComputedStyle(panel).position === 'absolute') return null const maxWidth = Math.max(PANEL_WIDTH.MIN, window.innerWidth * PANEL_WIDTH.MAX_PERCENTAGE) const newWidth = window.innerWidth - CONTENT_WINDOW_GAP - ev.clientX return Math.min(Math.max(newWidth, PANEL_WIDTH.MIN), maxWidth) @@ -18,15 +20,6 @@ function getPanelContainer(): HTMLElement | null { return document.querySelector('.panel-container') } -/** - * The toast stack also insets its right edge by `--panel-width`, but is - * portalled to `` and so shares no ancestor with the panel. See - * `use-terminal-resize.ts` for why this is written alongside the primary. - */ -function getToastViewport(): (HTMLElement | null)[] { - return [document.querySelector('[data-toast-viewport]')] -} - /** * Handles panel drag-resize with zero React renders during the drag. The * `--panel-width` variable is written to `.panel-container` (a scoped style @@ -43,8 +36,15 @@ export function usePanelResize() { cursor: 'ew-resize', cssVar: '--panel-width', getTarget: getPanelContainer, - getExtraTargets: getToastViewport, compute: computePanelWidth, - commit: setPanelWidth, + commit: (width) => { + const panel = getPanelContainer() + if (panel && getComputedStyle(panel).position === 'absolute') return + setPanelWidth(width) + }, + onStart: () => { + const panel = getPanelContainer() + return Boolean(panel && getComputedStyle(panel).position !== 'absolute') + }, }) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 7b5c6e25470..5f1ef0a22f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -7,6 +7,7 @@ import { Button, Chip, ChipConfirmModal, + cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -20,7 +21,7 @@ import { Trash, toast, } from '@sim/emcn' -import { BubbleChatDelay, Download, Lock, Plus, Unlock } from '@sim/emcn/icons' +import { BubbleChatDelay, Download, Lock, Plus, Unlock, X } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' @@ -90,6 +91,7 @@ import { useVariablesModalStore } from '@/stores/variables/modal' import { useVariablesStore } from '@/stores/variables/store' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { captureBaselineSnapshot } from '@/stores/workflow-diff/utils' +import { useWorkflowSearchReplaceStore } from '@/stores/workflow-search-replace/store' import { getWorkflowWithValues } from '@/stores/workflows' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -142,12 +144,16 @@ export const Panel = memo(function Panel() { const panelRef = useRef(null) const { + isMobilePanelOpen, + setIsMobilePanelOpen, activeTab: storedActiveTab, setActiveTab, _hasHydrated, setHasHydrated, } = usePanelStore( useShallow((state) => ({ + isMobilePanelOpen: state.isMobilePanelOpen, + setIsMobilePanelOpen: state.setIsMobilePanelOpen, activeTab: state.activeTab, setActiveTab: state.setActiveTab, _hasHydrated: state._hasHydrated, @@ -167,6 +173,8 @@ export const Panel = memo(function Panel() { const [isDuplicating, setIsDuplicating] = useState(false) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) + useEffect(() => () => setIsMobilePanelOpen(false), [setIsMobilePanelOpen]) + // Hooks const userPermissions = useUserPermissionsContext() const { config: permissionConfig } = usePermissionConfig() @@ -299,6 +307,10 @@ export const Panel = memo(function Panel() { })) ) + const hasCanvasOverlay = isChatOpen || isVariablesOpen + const isSearchOpen = useWorkflowSearchReplaceStore((state) => state.isOpen) + const isSearchingEditor = isSearchOpen && activeTab === 'editor' + const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null const workflowLocked = isWorkflowEffectivelyLocked(currentWorkflow, folders) const canMutateWorkflow = userPermissions.canEdit && !workflowLocked @@ -510,6 +522,18 @@ export const Panel = memo(function Panel() { [copilotSendMessage] ) + const openPanel = useCallback( + (tab: PanelTab) => { + if (panelRef.current && getComputedStyle(panelRef.current).position === 'absolute') { + setIsChatOpen(false) + setVariablesOpen(false) + } + setIsMobilePanelOpen(true) + setActiveTab(tab) + }, + [setIsChatOpen, setVariablesOpen, setIsMobilePanelOpen, setActiveTab] + ) + /** * Mark hydration as complete on mount * This allows React to take over visibility control from CSS @@ -533,14 +557,14 @@ export const Panel = memo(function Panel() { /** A mode-bearing send (Ask) belongs to the home chat, which has the mode; left unclaimed, it is stored for that surface. */ if (detail.requestMode) return e.preventDefault() - setActiveTab('copilot') + openPanel('copilot') copilotSendMessage(detail.message, detail.fileAttachments, detail.contexts, { ...(detail.resumeUserMessageId ? { resumeUserMessageId: detail.resumeUserMessageId } : {}), }) } window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) return () => window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) - }, [isCopilotTabAvailable, setActiveTab, copilotSendMessage]) + }, [isCopilotTabAvailable, openPanel, copilotSendMessage]) useEffect(() => { if (activeTab !== 'copilot') return @@ -713,7 +737,7 @@ export const Panel = memo(function Panel() { { id: 'focus-toolbar-search', handler: () => { - setActiveTab('toolbar') + openPanel('toolbar') toolbarRef.current?.focusSearch() }, overrides: { @@ -725,6 +749,21 @@ export const Panel = memo(function Panel() { return ( <> +
+ setIsMobilePanelOpen(true)} + > + Workflow + +
{showLimitRequest && memberLimitTarget && ( -
+
+
+ Workflow + +
{/* Header */} -
+
{/* More and Chat */}
- @@ -760,7 +833,12 @@ export const Panel = memo(function Panel() { Auto layout - setVariablesOpen(!isVariablesOpen)}> + { + setVariablesOpen(!isVariablesOpen) + setIsMobilePanelOpen(false) + }} + > Variables @@ -806,9 +884,12 @@ export const Panel = memo(function Panel() { @@ -854,7 +935,12 @@ export const Panel = memo(function Panel() {
{/* Tabs */} -
+
{isCopilotTabAvailable && (
-
- - { - if (event.key !== 'Enter') return - event.preventDefault() - handleMoveActiveMatch(event.shiftKey ? -1 : 1) - }} - onChange={(event) => setQuery(event.target.value)} - /> - - - - {isReplaceExpanded && ( -
- 0 && hasReplacement && !allReplacementIssue +
+
+
- )} + + { + if (event.key !== 'Enter') return + event.preventDefault() + handleMoveActiveMatch(event.shiftKey ? -1 : 1) + }} + onChange={(event) => setQuery(event.target.value)} + /> + + + + {isReplaceExpanded && ( +
+ 0 && hasReplacement && !allReplacementIssue + )} + onReplacementChange={handleReplacementChange} + onReplaceActive={handleReplaceActive} + onReplaceAll={handleReplaceAll} + /> +
+ )} +
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx index 7eb787be542..e41f422c9da 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx @@ -16,6 +16,7 @@ import { ArrowDown, ArrowUp, Check, + ChevronLeft, Clipboard, Download, MoreHorizontal, @@ -86,6 +87,7 @@ const OutputCodeContent = React.memo(function OutputCodeContent({ * are accessed directly from useTerminalStore to reduce prop drilling. */ export interface OutputPanelProps { + onBackToLogs: () => void selectedEntry: ConsoleEntry handleOutputPanelResizePointerDown: (e: React.PointerEvent) => void handleHeaderClick: () => void @@ -110,6 +112,7 @@ export interface OutputPanelProps { * Accesses store-backed settings directly to reduce prop drilling. */ export const OutputPanel = React.memo(function OutputPanel({ + onBackToLogs, selectedEntry, handleOutputPanelResizePointerDown, handleHeaderClick, @@ -290,12 +293,12 @@ export const OutputPanel = React.memo(function OutputPanel({ return ( <>
{/* Horizontal Resize Handle */}
+
)} @@ -493,7 +508,7 @@ export const OutputPanel = React.memo(function OutputPanel({ {/* Search Overlay */} {isOutputSearchActive && (
e.stopPropagation()} data-toolbar-root data-search-active='true' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/toggle-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/toggle-button.tsx index e23a97cb40b..5b6de508afb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/toggle-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/toggle-button.tsx @@ -18,13 +18,14 @@ export const ToggleButton = memo(function ToggleButton({ isExpanded, onClick }: + - - -
-
- {mode === 'hand' ? 'Mover' : 'Pointer'} - - - { - setMode('hand') - setIsCanvasModeOpen(false) - }} - > - - Mover - - { - setMode('cursor') - setIsCanvasModeOpen(false) - }} - > - - Pointer - - -
- -
+
+ + {mode === 'hand' ? 'Mover' : 'Pointer'} + + + { + setMode('hand') + setIsCanvasModeOpen(false) + }} + > + + Mover + + { + setMode('cursor') + setIsCanvasModeOpen(false) + }} + > + + Pointer + + + + )} + +
+ ) : ( + requestLeave(() => router.push(popSettingsReturnUrl(APP_ENTRY_PATH)))} + > + Back + + )} + ({ value: item.id, label: item.label, icon: item.icon }))} + onChange={(value) => { + const item = items.find((candidate) => candidate.id === value) + if (!item || item.id === selectedSection) return + requestLeave(() => navigateToSection(item.id, hrefForSection(item.id))) + }} + align='end' + modal={false} + searchable + className='min-w-0 max-w-full' + /> + + ) +} diff --git a/apps/sim/components/settings/settings-header.tsx b/apps/sim/components/settings/settings-header.tsx index 91894c37775..baf07ad5414 100644 --- a/apps/sim/components/settings/settings-header.tsx +++ b/apps/sim/components/settings/settings-header.tsx @@ -167,6 +167,7 @@ interface SettingsActionChipProps { onSelect?: () => void /** Overrides `action.onPrefetch`, same reason. */ onPrefetch?: () => void + mobileIconOnly?: boolean } /** @@ -179,12 +180,14 @@ export function SettingsActionChip({ action, onSelect = action.onSelect, onPrefetch = action.onPrefetch, + mobileIconOnly, }: SettingsActionChipProps) { const chip = (
{(title || description) && ( diff --git a/apps/sim/components/settings/settings-sidebar.tsx b/apps/sim/components/settings/settings-sidebar.tsx index 1131009226a..0e260991934 100644 --- a/apps/sim/components/settings/settings-sidebar.tsx +++ b/apps/sim/components/settings/settings-sidebar.tsx @@ -23,7 +23,7 @@ import { } from '@/components/settings/navigation' import { SettingsIntentLink } from '@/components/settings/settings-intent-link' import { usePendingSettingsSelection } from '@/components/settings/use-pending-settings-selection' -import { APP_ENTRY_PATH } from '@/lib/navigation/paths' +import { APP_ENTRY_PATH, LANDING_HREF } from '@/lib/navigation/paths' import { popSettingsReturnUrl } from '@/lib/navigation/settings-return' import { SidebarSection } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section' import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip' @@ -36,12 +36,6 @@ import { } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' -/** - * The marketing landing page. `?home` is required: the proxy bounces a - * signed-in user off `/` to the app entry unless the param is present. - */ -const LANDING_HREF = '/?home' - interface SettingsNavigationGroup { key: string title: string diff --git a/apps/sim/components/settings/standalone-settings-shell.tsx b/apps/sim/components/settings/standalone-settings-shell.tsx index 04e45d3b64a..5fb8bf8d2d9 100644 --- a/apps/sim/components/settings/standalone-settings-shell.tsx +++ b/apps/sim/components/settings/standalone-settings-shell.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react' import { cn } from '@sim/emcn' import { usePathname } from 'next/navigation' +import { SettingsMobileNavigation } from '@/components/settings/mobile-navigation' import { ACCOUNT_SETTINGS_GROUPS, ACCOUNT_SETTINGS_ITEMS, @@ -104,7 +105,7 @@ export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) { return ( -
+
{/* Mirrors the in-workspace chrome (WorkspaceChrome): a flush, borderless sidebar column against the app surface, meeting the content pane on a @@ -113,7 +114,7 @@ export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) { */}