Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 22 additions & 11 deletions apps/sim/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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) {
Expand All @@ -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');
Expand Down Expand Up @@ -258,11 +267,13 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
<PostHogProvider>
<ThemeProvider>
<QueryProvider>
<SessionProvider>
<TooltipProvider>
<BrandedLayout>{children}</BrandedLayout>
</TooltipProvider>
</SessionProvider>
<NuqsAdapter>
<SessionProvider>
<TooltipProvider>
<BrandedLayout>{children}</BrandedLayout>
</TooltipProvider>
</SessionProvider>
</NuqsAdapter>
</QueryProvider>
</ThemeProvider>
</PostHogProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -313,6 +314,10 @@ export const Toolbar = memo(
// Search state
const [isSearchActive, setIsSearchActive] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [isMac, setIsMac] = useState<boolean | null>(null)
useEffect(() => {
setIsMac(isMacPlatform())
}, [])
const [prevIsActive, setPrevIsActive] = useState(isActive)
if (isActive !== prevIsActive) {
setPrevIsActive(isActive)
Expand Down Expand Up @@ -729,14 +734,31 @@ export const Toolbar = memo(
<h2 className='font-medium text-[var(--text-primary)] text-sm'>Toolbar</h2>
<div className='flex shrink-0 items-center gap-2'>
{!isSearchActive ? (
<Button
variant='ghost'
className='p-0'
aria-label='Search toolbar'
onClick={handleSearchClick}
>
<Search className='h-[14px] w-[14px]' />
</Button>
<>
{isMac !== null && (
<kbd className='inline-flex items-center gap-0.5 rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] px-1 py-0.5 font-mono font-normal text-[11px] text-[var(--text-muted)] leading-none'>
{isMac ? (
<>
<span className='flex items-center gap-0.5'>
<Option className='h-[10px] w-[10px]' strokeWidth={2.5} />
<Command className='h-[10px] w-[10px]' strokeWidth={2.5} />
</span>
<span>F</span>
</>
) : (
<span>Ctrl+Alt+F</span>
)}
</kbd>
)}
<Button
variant='ghost'
className='p-0'
aria-label='Search toolbar'
onClick={handleSearchClick}
>
<Search className='h-[14px] w-[14px]' />
</Button>
</>
) : (
<input
ref={searchInputRef}
Expand All @@ -762,9 +784,17 @@ export const Toolbar = memo(
>
<div
ref={triggersHeaderRef}
className='px-2.5 pt-1.5 pb-1.5 font-medium text-[var(--text-primary)] text-small'
className='flex items-center gap-1.5 px-2.5 pt-1.5 pb-1.5 font-medium text-[var(--text-primary)] text-small'
>
Triggers
<span>Triggers</span>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Info className='h-[14px] w-[14px] cursor-default text-[var(--text-muted)]' />
</Tooltip.Trigger>
<Tooltip.Content side='top' align='start'>
<p>Events that start a workflow.</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<div className='flex-1 overflow-y-auto overflow-x-hidden px-1.5'>
<div ref={triggersContentRef} className='space-y-1 pb-2'>
Expand Down Expand Up @@ -796,9 +826,20 @@ export const Toolbar = memo(
<div
ref={blocksHeaderRef}
onClick={handleBlocksHeaderClick}
className='cursor-pointer px-2.5 pt-1.5 pb-1.5 font-medium text-[var(--text-primary)] text-small'
className='flex cursor-pointer items-center gap-1.5 px-2.5 pt-1.5 pb-1.5 font-medium text-[var(--text-primary)] text-small'
>
Blocks
<span>Blocks</span>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Info
className='h-[14px] w-[14px] cursor-default text-[var(--text-muted)]'
onClick={(e) => e.stopPropagation()}
/>
</Tooltip.Trigger>
<Tooltip.Content side='top' align='start'>
<p>Actions that make up the steps of a workflow.</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<div className='flex-1 overflow-y-auto overflow-x-hidden px-1.5'>
<div className='space-y-1 pb-2'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 `<html>`, 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
Expand All @@ -125,15 +124,25 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel

const panelRef = useRef<HTMLElement>(null)
const fileInputRef = useRef<HTMLInputElement>(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)
},
[setActiveTabRaw]
)
const toolbarRef = useRef<{
focusSearch: () => void
} | null>(null)
Expand Down Expand Up @@ -442,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(() => {
Expand All @@ -455,6 +470,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<PanelTab>).detail
if (tab === 'copilot' || tab === 'toolbar' || tab === 'editor') {
setActiveTab(tab)
}
}
window.addEventListener('panel:set-tab', handler)
return () => window.removeEventListener('panel:set-tab', handler)
}, [setActiveTab])
Comment on lines +473 to +482
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 panel:set-tab event is fire-and-forget — missed events leave the tab unchanged

requestEditorTab() dispatches a synchronous CustomEvent on window, and the Panel only processes it while mounted with an active listener. If the Panel is mid-unmount/remount (e.g. during a fast route transition followed by an immediate block click), the event fires into a gap where no listener is registered and the URL never updates — the editor tab silently fails to open. A safety net like checking activeTab after a microtask, or using a small shared atom/signal instead of a one-shot event, would make the bridge more resilient.


useEffect(() => {
if (activeTab !== 'copilot') return
const id = window.setTimeout(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated PANEL_TABS array risks silent desynchronization

Low Severity

The PANEL_TABS constant is independently defined in both use-block-visual.ts and panel.tsx. Both must stay in sync with the PanelTab type in @/stores/panel/types.ts, which already serves as the source of truth for valid tab values. If a tab is added or renamed, only one array might get updated, causing the nuqs parser in the other file to silently reject the new value and fall back to 'copilot'. Extracting a single shared PANEL_TABS array next to the PanelTab type would eliminate this drift risk.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6848b92. Configure here.


/**
* Props for the useBlockVisual hook.
Comment on lines +11 to 14
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 PANEL_TABS constant is duplicated across files

The same ['copilot', 'toolbar', 'editor'] as const array is defined independently in both use-block-visual.ts and panel.tsx. PanelTab is already exported from stores/panel/types.ts, so the backing tuple could live there too, giving a single source of truth for the literal union.

Suggested change
const PANEL_TABS = ['copilot', 'toolbar', 'editor'] as const
/**
* Props for the useBlockVisual hook.
// Consider exporting PANEL_TABS from '@/stores/panel/types' and importing it here
// to avoid duplicating the constant that also lives in panel.tsx.
const PANEL_TABS = ['copilot', 'toolbar', 'editor'] as const
/**
* Props for the useBlockVisual hook.

*/
Expand Down Expand Up @@ -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'
Comment on lines +60 to +61
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 All blocks re-render on every panel tab switch

useQueryState hooks are notified via React context whenever the ?panel= URL param changes, so every block on the canvas will re-render each time the user switches tabs. The previous Zustand selector approach short-circuited early — if (isPreview || isEmbedded || !isThisBlockInEditor) return false — so Zustand never subscribed those blocks to activeTab mutations. For workflows with many blocks, switching tabs will now trigger N extra renders instead of at most 1. Consider moving the panelTab === 'editor' check back into the usePanelEditorStore selector (e.g. adding activeTab back to the store as a non-persisted field, or deriving it from the URL once in the Panel and propagating it down via a lightweight context).


const lastRunPath = useLastRunPath()
const runPathStatus = isPreview ? undefined : lastRunPath.get(blockId)
Expand Down
1 change: 1 addition & 0 deletions apps/sim/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 12 additions & 3 deletions apps/sim/stores/panel/editor/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,13 +72,13 @@ export const usePanelEditorStore = create<PanelEditorState>()(
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: () => {
Expand Down
19 changes: 2 additions & 17 deletions apps/sim/stores/panel/store.ts
Original file line number Diff line number Diff line change
@@ -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<PanelState>()(
persist(
Expand All @@ -21,14 +16,6 @@ export const usePanelStore = create<PanelState>()(
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 })
Expand All @@ -40,12 +27,10 @@ export const usePanelStore = create<PanelState>()(
}),
{
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')
}
},
}
Expand Down
Loading
Loading