From 9bc97e4dd11518b08f89f7e714cec71cfca35c5b Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Fri, 8 May 2026 18:41:20 -0400 Subject: [PATCH 1/2] add tooltip to blocks types, add shortcut kbd to search, move open tab to url state --- apps/sim/app/layout.tsx | 33 ++++++--- .../panel/components/toolbar/toolbar.tsx | 69 +++++++++++++++---- .../w/[workflowId]/components/panel/panel.tsx | 52 ++++++++++---- .../w/[workflowId]/hooks/use-block-visual.ts | 17 ++--- apps/sim/package.json | 1 + apps/sim/stores/panel/editor/store.ts | 15 +++- apps/sim/stores/panel/store.ts | 19 +---- apps/sim/stores/panel/types.ts | 9 ++- bun.lock | 6 +- 9 files changed, 147 insertions(+), 74 deletions(-) diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 326a18a6f00..46347ba3b63 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from 'next' import Script from 'next/script' import { PublicEnvScript } from 'next-runtime-env' +import { NuqsAdapter } from 'nuqs/adapters/next/app' import { BrandedLayout } from '@/components/branded-layout' import { PostHogProvider } from '@/app/_shell/providers/posthog-provider' import { generateBrandedMetadata, generateThemeCSS } from '@/ee/whitelabeling' @@ -104,7 +105,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth); } - // Panel width and active tab + // Panel width try { var panelStored = localStorage.getItem('panel-state'); if (panelStored) { @@ -118,16 +119,24 @@ export default function RootLayout({ children }: { children: React.ReactNode }) } else if (panelWidth > maxPanelWidth) { document.documentElement.style.setProperty('--panel-width', maxPanelWidth + 'px'); } - - var activeTab = panelState && panelState.activeTab; - if (activeTab) { - document.documentElement.setAttribute('data-panel-active-tab', activeTab); - } } } catch (e) { // Fallback handled by CSS defaults } + // Panel active tab — sourced from the URL so a hard refresh paints + // the correct tab before React hydrates (no copilot → editor flash). + try { + var panelTab = new URLSearchParams(window.location.search).get('panel'); + if (panelTab === 'copilot' || panelTab === 'toolbar' || panelTab === 'editor') { + document.documentElement.setAttribute('data-panel-active-tab', panelTab); + } else { + document.documentElement.setAttribute('data-panel-active-tab', 'copilot'); + } + } catch (e) { + document.documentElement.setAttribute('data-panel-active-tab', 'copilot'); + } + // Toolbar triggers height try { var toolbarStored = localStorage.getItem('toolbar-state'); @@ -258,11 +267,13 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= - - - {children} - - + + + + {children} + + + diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx index 0f6961e760c..61048dc7bc6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx @@ -11,9 +11,10 @@ import { useState, } from 'react' import clsx from 'clsx' -import { Search } from 'lucide-react' +import { Command, Info, Option, Search } from 'lucide-react' import { usePostHog } from 'posthog-js/react' -import { Button } from '@/components/emcn' +import { Button, Tooltip } from '@/components/emcn' +import { isMacPlatform } from '@/lib/core/utils/platform' import { captureEvent } from '@/lib/posthog/client' import { getBlocksForSidebar, @@ -313,6 +314,10 @@ export const Toolbar = memo( // Search state const [isSearchActive, setIsSearchActive] = useState(false) const [searchQuery, setSearchQuery] = useState('') + const [isMac, setIsMac] = useState(null) + useEffect(() => { + setIsMac(isMacPlatform()) + }, []) const [prevIsActive, setPrevIsActive] = useState(isActive) if (isActive !== prevIsActive) { setPrevIsActive(isActive) @@ -729,14 +734,31 @@ export const Toolbar = memo(

Toolbar

{!isSearchActive ? ( - + <> + {isMac !== null && ( + + {isMac ? ( + <> + + + F + + ) : ( + Ctrl+Alt+F + )} + + )} + + ) : (
- Triggers + Triggers + + + + + +

Events that start a workflow.

+
+
@@ -796,9 +826,20 @@ export const Toolbar = memo(
- Blocks + Blocks + + + e.stopPropagation()} + /> + + +

Actions that make up the steps of a workflow.

+
+
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 78ad3020518..bec8ab6cce4 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 @@ -6,6 +6,7 @@ import { toError } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' import { History, Plus } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' +import { parseAsStringLiteral, useQueryState } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { useShallow } from 'zustand/react/shallow' import { @@ -94,22 +95,20 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('Panel') const EMPTY_COPILOT_CHATS: readonly CopilotChatListItem[] = [] /** - * Panel component with resizable width and tab navigation that persists across page refreshes. + * Panel component with resizable width and tab navigation. * - * Uses a CSS-based approach to prevent hydration mismatches and flash on load: - * 1. Width is controlled by CSS variable (--panel-width) - * 2. Blocking script in layout.tsx sets CSS variable and data-panel-active-tab before React hydrates - * 3. CSS rules control initial visibility based on data-panel-active-tab attribute - * 4. React takes over visibility control after hydration completes - * 5. Store updates CSS variable when width changes + * The active tab is stored in the URL (`?panel=...`) via nuqs so a hard refresh + * paints the correct tab before React hydrates — no copilot-then-editor flash. + * The blocking script in layout.tsx reads the same query param and sets + * `data-panel-active-tab` on ``, which CSS uses to hide the inactive tabs + * before paint. * - * This ensures server and client render identical HTML, preventing hydration errors and visual flash. - * - * Note: All tabs are kept mounted but hidden to preserve component state during tab switches. - * This prevents unnecessary remounting which would trigger data reloads and reset state. + * All tabs stay mounted but hidden to preserve component state across switches. * * @returns Panel on the right side of the workflow */ +const PANEL_TABS = ['copilot', 'toolbar', 'editor'] as const + interface PanelProps { /** Override workspaceId when rendered outside a workspace route (e.g. sandbox mode) */ workspaceId?: string @@ -125,15 +124,29 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const panelRef = useRef(null) const fileInputRef = useRef(null) - const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore( + const { panelWidth, _hasHydrated, setHasHydrated } = usePanelStore( useShallow((state) => ({ - activeTab: state.activeTab, - setActiveTab: state.setActiveTab, panelWidth: state.panelWidth, _hasHydrated: state._hasHydrated, setHasHydrated: state.setHasHydrated, })) ) + const [activeTab, setActiveTabRaw] = useQueryState( + 'panel', + parseAsStringLiteral(PANEL_TABS) + .withDefault('copilot') + .withOptions({ history: 'replace', clearOnDefault: true }) + ) + const setActiveTab = useCallback( + (tab: PanelTab) => { + void setActiveTabRaw(tab) + // Drop the pre-hydration data attribute once React owns visibility. + if (typeof document !== 'undefined') { + document.documentElement.removeAttribute('data-panel-active-tab') + } + }, + [setActiveTabRaw] + ) const toolbarRef = useRef<{ focusSearch: () => void } | null>(null) @@ -455,6 +468,17 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel return () => window.removeEventListener('mothership-send-message', handler) }, [setActiveTab, copilotSendMessage]) + useEffect(() => { + const handler = (e: Event) => { + const tab = (e as CustomEvent).detail + if (tab === 'copilot' || tab === 'toolbar' || tab === 'editor') { + setActiveTab(tab) + } + } + window.addEventListener('panel:set-tab', handler) + return () => window.removeEventListener('panel:set-tab', handler) + }, [setActiveTab]) + useEffect(() => { if (activeTab !== 'copilot') return const id = window.setTimeout(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts index 8fc65c65442..d0697b7a8a6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts @@ -1,12 +1,15 @@ import { useCallback, useMemo } from 'react' +import { parseAsStringLiteral, useQueryState } from 'nuqs' import { useBlockState } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks' import type { WorkflowBlockProps } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow' import { getBlockRingStyles } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils' import { useLastRunPath } from '@/stores/execution' -import { usePanelEditorStore, usePanelStore } from '@/stores/panel' +import { usePanelEditorStore } from '@/stores/panel' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +const PANEL_TABS = ['copilot', 'toolbar', 'editor'] as const + /** * Props for the useBlockVisual hook. */ @@ -54,16 +57,8 @@ export function useBlockVisual({ const currentBlockId = usePanelEditorStore((state) => state.currentBlockId) const isThisBlockInEditor = currentBlockId === blockId - const activeTabIsEditor = usePanelStore( - useCallback( - (state) => { - if (isPreview || isEmbedded || !isThisBlockInEditor) return false - return state.activeTab === 'editor' - }, - [isPreview, isEmbedded, isThisBlockInEditor] - ) - ) - const isEditorOpen = !isPreview && !isEmbedded && isThisBlockInEditor && activeTabIsEditor + const [panelTab] = useQueryState('panel', parseAsStringLiteral(PANEL_TABS).withDefault('copilot')) + const isEditorOpen = !isPreview && !isEmbedded && isThisBlockInEditor && panelTab === 'editor' const lastRunPath = useLastRunPath() const runPathStatus = isPreview ? undefined : lastRunPath.get(blockId) diff --git a/apps/sim/package.json b/apps/sim/package.json index 2304b992c53..c8cfc53b025 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -165,6 +165,7 @@ "next-runtime-env": "3.3.0", "next-themes": "^0.4.6", "nodemailer": "8.0.7", + "nuqs": "2.8.9", "officeparser": "^5.2.0", "openai": "^4.91.1", "papaparse": "5.5.3", diff --git a/apps/sim/stores/panel/editor/store.ts b/apps/sim/stores/panel/editor/store.ts index d76019befea..f8650d57777 100644 --- a/apps/sim/stores/panel/editor/store.ts +++ b/apps/sim/stores/panel/editor/store.ts @@ -3,10 +3,19 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import { EDITOR_CONNECTIONS_HEIGHT } from '@/stores/constants' -import { usePanelStore } from '../store' let renameCallback: (() => void) | null = null +/** + * Asks the workflow panel to switch to the editor tab. The active tab lives + * in the URL via nuqs, which is React-only, so we hop through a window event + * that the panel component listens for. + */ +function requestEditorTab() { + if (typeof window === 'undefined') return + window.dispatchEvent(new CustomEvent('panel:set-tab', { detail: 'editor' })) +} + export interface ActiveSearchTarget { matchId: string blockId: string @@ -63,13 +72,13 @@ export const usePanelEditorStore = create()( setCurrentBlockId: (blockId) => { set({ currentBlockId: blockId }) if (blockId !== null) { - usePanelStore.getState().setActiveTab('editor') + requestEditorTab() } }, setActiveSearchTarget: (target) => { set({ activeSearchTarget: target }) if (target) { - usePanelStore.getState().setActiveTab('editor') + requestEditorTab() } }, clearCurrentBlock: () => { diff --git a/apps/sim/stores/panel/store.ts b/apps/sim/stores/panel/store.ts index 5e7d0c74015..eb9c35417e7 100644 --- a/apps/sim/stores/panel/store.ts +++ b/apps/sim/stores/panel/store.ts @@ -1,12 +1,7 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import { PANEL_WIDTH } from '@/stores/constants' -import type { PanelState, PanelTab } from '@/stores/panel/types' - -/** - * Default panel tab - */ -const DEFAULT_TAB: PanelTab = 'copilot' +import type { PanelState } from '@/stores/panel/types' export const usePanelStore = create()( persist( @@ -21,14 +16,6 @@ export const usePanelStore = create()( document.documentElement.style.setProperty('--panel-width', `${clampedWidth}px`) } }, - activeTab: DEFAULT_TAB, - setActiveTab: (tab) => { - set({ activeTab: tab }) - // Remove data attribute once React takes control - if (typeof document !== 'undefined') { - document.documentElement.removeAttribute('data-panel-active-tab') - } - }, isResizing: false, setIsResizing: (isResizing) => { set({ isResizing }) @@ -40,12 +27,10 @@ export const usePanelStore = create()( }), { name: 'panel-state', + partialize: (state) => ({ panelWidth: state.panelWidth }), onRehydrateStorage: () => (state) => { - // Sync CSS variables with stored state after rehydration if (state && typeof window !== 'undefined') { document.documentElement.style.setProperty('--panel-width', `${state.panelWidth}px`) - // Remove the data attribute so CSS rules stop interfering - document.documentElement.removeAttribute('data-panel-active-tab') } }, } diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts index f60a3f6a513..8c169e8f894 100644 --- a/apps/sim/stores/panel/types.ts +++ b/apps/sim/stores/panel/types.ts @@ -4,13 +4,16 @@ export type PanelTab = 'copilot' | 'editor' | 'toolbar' /** - * Panel state interface + * Panel state interface. + * + * @remarks + * The active tab is intentionally not stored here — it lives in the URL + * (`?panel=...`) via nuqs so a hard refresh can paint the correct tab + * before React hydrates. */ export interface PanelState { panelWidth: number setPanelWidth: (width: number) => void - activeTab: PanelTab - setActiveTab: (tab: PanelTab) => void /** Whether the panel is currently being resized */ isResizing: boolean /** Updates the panel resize state */ diff --git a/bun.lock b/bun.lock index 9c5e1dedc68..8daca704883 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -220,6 +219,7 @@ "next-runtime-env": "3.3.0", "next-themes": "^0.4.6", "nodemailer": "8.0.7", + "nuqs": "2.8.9", "officeparser": "^5.2.0", "openai": "^4.91.1", "papaparse": "5.5.3", @@ -3241,6 +3241,8 @@ "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "nuqs": ["nuqs@2.8.9", "", { "dependencies": { "@standard-schema/spec": "1.0.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^5 || ^6 || ^7", "react-router-dom": "^5 || ^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router", "react-router-dom"] }, "sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ=="], + "nwsapi": ["nwsapi@2.2.23", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="], "nypm": ["nypm@0.6.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "pathe": "^2.0.3", "pkg-types": "^2.0.0", "tinyexec": "^0.3.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg=="], @@ -4793,6 +4795,8 @@ "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + "nuqs/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + "nypm/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], "oauth2-mock-server/express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="], From 6848b921beb55336d9e1a46e7fddd023c477afc0 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Fri, 8 May 2026 18:44:55 -0400 Subject: [PATCH 2/2] remove redundant data attribute cleanup from setActiveTab --- .../w/[workflowId]/components/panel/panel.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 bec8ab6cce4..3e82e326254 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 @@ -140,10 +140,6 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const setActiveTab = useCallback( (tab: PanelTab) => { void setActiveTabRaw(tab) - // Drop the pre-hydration data attribute once React owns visibility. - if (typeof document !== 'undefined') { - document.documentElement.removeAttribute('data-panel-active-tab') - } }, [setActiveTabRaw] ) @@ -455,6 +451,12 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel */ useEffect(() => { setHasHydrated(true) + // The blocking script in layout.tsx pins this attribute pre-hydration to + // hide inactive tabs via CSS. Once React owns visibility, drop it so the + // CSS `!important` rules don't fight React's className-based toggling. + if (typeof document !== 'undefined') { + document.documentElement.removeAttribute('data-panel-active-tab') + } }, [setHasHydrated]) useEffect(() => {