From dd2b2b1ba9f7aaa6aa2de2f664ba4b7ffac4b840 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 8 Oct 2026 23:20:27 -0700 Subject: [PATCH 01/13] improvement(mobile): refine responsive chat and workspace layouts --- .agents/skills/emcn-design-review/SKILL.md | 4 + .claude/rules/sim-testing.md | 5 +- .cursor/rules/sim-testing.mdc | 5 +- .github/scripts/http-e2e.sh | 17 +- .github/workflows/checks.yml | 8 +- .../(interfaces)/chat/[identifier]/chat.tsx | 26 +- .../chat/components/header/header.tsx | 20 +- .../chat/components/input/input.tsx | 20 +- .../input/public-chat-action-button.tsx | 8 +- .../message-container/message-container.tsx | 8 +- .../chat/components/message/message.tsx | 16 +- .../allowed-integrations/route.test.ts | 5 +- .../settings/allowed-integrations/route.ts | 36 +- apps/sim/app/layout.tsx | 1 + .../components/organization-landing.tsx | 2 +- .../organization-sidebar.tsx | 13 +- .../components/search-landing-history.tsx | 2 +- .../home/components/composer/composer.tsx | 5 +- .../home/organization-home.tsx | 6 +- .../app/o/[organizationId]/layout.test.tsx | 1 + apps/sim/app/o/[organizationId]/layout.tsx | 9 +- .../components/credential-detail-layout.tsx | 2 +- .../message-actions/message-actions.tsx | 2 +- .../components/action-bar/action-bar.tsx | 6 +- .../resource-header/resource-header.tsx | 75 +- .../resource-options/resource-options.tsx | 28 +- .../components/resource/resource.tsx | 36 +- .../components/workspace-chrome/index.ts | 1 + .../sidebar-chrome-context.tsx | 10 +- .../workspace-chrome/workspace-chrome.tsx | 108 +- .../workspace-chrome/workspace-viewport.tsx | 27 + .../file-viewer/preview-toolbar.tsx | 14 +- .../rich-markdown-editor/code-block.tsx | 2 +- .../rich-markdown-editor/code-highlight.ts | 8 +- .../code-languages.test.ts | 19 - .../file-viewer/zoomable-preview.tsx | 41 +- .../home/components/chat-panel-layout.tsx | 37 +- .../home/components/chat-resource-panel.tsx | 1 + .../components/chat-content/chat-content.tsx | 10 +- .../mothership-chat/mothership-chat.tsx | 18 +- .../resource-tabs/resource-tab-controls.ts | 2 +- .../suggested-actions/suggested-actions.tsx | 2 +- .../attached-files-list.tsx | 81 +- .../user-input/components/constants.ts | 2 +- .../user-input/components/input-toolbar.tsx | 4 +- .../prompt-editor/use-prompt-editor.ts | 15 +- .../home/components/user-input/user-input.tsx | 9 +- .../app/workspace/[workspaceId]/home/home.tsx | 9 +- .../home/hooks/use-resource-panel.ts | 46 +- .../[id]/components/action-bar/action-bar.tsx | 7 +- .../workspace/[workspaceId]/layout.test.tsx | 1 + .../app/workspace/[workspaceId]/layout.tsx | 9 +- .../[workspaceId]/prefetch-access.test.tsx | 16 +- .../[workspaceId]/prefetch-access.ts | 32 +- .../components/activity-log/activity-log.tsx | 58 +- .../components/appearance-theme-select.tsx | 2 +- .../components/default-zoom-select.tsx | 2 +- .../settings/components/general/general.tsx | 8 +- .../terminal/terminal-theme-picker.tsx | 2 +- .../column-config-sidebar.tsx | 28 +- .../components/columns-menu/columns-menu.tsx | 2 +- .../enrichments-sidebar/enrichment-config.tsx | 21 +- .../enrichments-sidebar.tsx | 10 +- .../run-status-control/run-status-control.tsx | 2 +- .../table-action-bar/table-action-bar.tsx | 4 +- .../cells/expanded-cell-popover.tsx | 16 +- .../components/table-grid/data-row.tsx | 33 +- .../table-grid/headers/column-header-menu.tsx | 4 +- .../headers/workflow-group-meta-cell.tsx | 2 +- .../workflow-sidebar/workflow-sidebar.tsx | 23 +- .../[workspaceId]/tables/[tableId]/table.tsx | 1 + .../components/chat/chat.test.tsx | 5 + .../w/[workflowId]/components/chat/chat.tsx | 104 +- .../panel/hooks/use-panel-resize.ts | 14 +- .../w/[workflowId]/components/panel/panel.tsx | 51 +- .../components/output-panel/output-panel.tsx | 27 +- .../toggle-button/toggle-button.tsx | 5 +- .../components/terminal/terminal.tsx | 51 +- .../workflow-controls/workflow-controls.tsx | 121 ++- .../hooks/float/use-float-boundary-sync.ts | 26 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 25 +- .../components/sidebar-row-actions.tsx | 2 +- .../sidebar-section/sidebar-section.tsx | 4 +- .../w/components/sidebar/sidebar.tsx | 11 +- apps/sim/components/page-header-bar.ts | 5 +- .../components/settings/mobile-navigation.tsx | 55 + .../components/settings/settings-header.tsx | 5 +- .../settings/standalone-settings-shell.tsx | 20 +- .../hooks/queries/integration-availability.ts | 11 +- .../utils/integration-availability-keys.ts | 6 + apps/sim/hooks/use-auto-size-textarea.ts | 14 +- apps/sim/hooks/use-canvas-viewport.ts | 6 +- apps/sim/hooks/use-chat-input-focus.ts | 3 +- apps/sim/hooks/use-is-mobile.ts | 25 + apps/sim/hooks/use-mobile-viewport.ts | 38 + apps/sim/hooks/use-permission-config.test.tsx | 2 +- .../application/read-availability.ts | 44 + apps/sim/package.json | 2 + apps/sim/scripts/test-mobile-e2e.ts | 982 ++++++++++++++++++ apps/sim/stores/sidebar/store.test.ts | 4 +- apps/sim/stores/sidebar/store.ts | 9 +- bun.lock | 15 +- .../emcn/src/components/banner/banner.tsx | 5 +- .../bulk-action-button/bulk-action-button.tsx | 2 +- .../src/components/chip-input/chip-input.tsx | 4 +- .../emcn/src/components/chip/chip-chrome.ts | 2 +- packages/emcn/src/components/chip/chip.tsx | 44 +- .../src/components/chip/segmented-control.ts | 2 +- .../emcn/src/components/code/prism-core.d.ts | 4 + packages/emcn/src/components/code/prism.ts | 21 +- .../emcn/src/components/combobox/combobox.tsx | 4 +- .../composer-action-button.tsx | 61 +- .../details-panel/details-panel.tsx | 4 +- .../dropdown-menu/dropdown-menu.tsx | 2 +- .../src/components/dropdown-menu/styles.ts | 4 +- packages/emcn/src/components/input/input.tsx | 2 +- packages/emcn/src/components/modal/modal.tsx | 40 +- .../emcn/src/components/popover/popover.tsx | 2 +- .../emcn/src/components/textarea/textarea.tsx | 2 +- packages/emcn/src/components/toast/toast.tsx | 9 +- .../emcn/src/components/tooltip/tooltip.tsx | 2 + ...check-tool-registry-boundary.baseline.json | 666 ++++++------ scripts/check-unused-exports.baseline.json | 1 - 123 files changed, 2772 insertions(+), 918 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-viewport.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-languages.test.ts create mode 100644 apps/sim/components/settings/mobile-navigation.tsx create mode 100644 apps/sim/hooks/queries/utils/integration-availability-keys.ts create mode 100644 apps/sim/hooks/use-is-mobile.ts create mode 100644 apps/sim/hooks/use-mobile-viewport.ts create mode 100644 apps/sim/lib/integrations/application/read-availability.ts create mode 100644 apps/sim/scripts/test-mobile-e2e.ts create mode 100644 packages/emcn/src/components/code/prism-core.d.ts 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 ` @@ -215,7 +216,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} + -
+
+
{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} ))} {isToggleFilter && filter.mode === 'toggle' ? ( - + Filter ) : popoverFilter ? ( @@ -176,7 +186,7 @@ export const ResourceOptions = memo(function ResourceOptions({
- + Filter @@ -221,9 +231,9 @@ export const ResourceOptions = memo(function ResourceOptions({ const SearchSection = memo(function SearchSection({ search }: { search: SearchConfig }) { return ( -
+
{SEARCH_ICON} -
+
{search.tags?.map((tag, i) => ( - + 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..e2ca16f7f28 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' @@ -186,7 +187,7 @@ interface ResourceProps { function ResourceRoot({ children, onContextMenu }: ResourceProps) { return (
{children} @@ -387,7 +388,15 @@ const ResourceTable = memo(function ResourceTable({ onDragLeave={bodyDrop?.onDragLeave} onDrop={bodyDrop?.onDrop} > -
+
{col.header}
@@ -770,10 +779,25 @@ const DataRow = memo(function DataRow({ />
)} - {columns.map((col) => { + {columns.map((col, index) => { const cell = row.cells[col.id] return ( -
+
+ {index === 0 && onRowContextMenu && ( + { + event.stopPropagation() + onRowContextMenu(event, row.id) + }} + /> + )} 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..085f585c392 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,25 @@ 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..24d938ebe12 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,20 @@ 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 && ( -
+
+ + {toggleLabel} + + {!collapsed && ( + Back to chat + )} - + {!collapsed && ( + + )} + {collapsed && activityCount > 0 && (