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 78ad302051..3e82e32625 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,25 @@ 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)
+ },
+ [setActiveTabRaw]
+ )
const toolbarRef = useRef<{
focusSearch: () => void
} | null>(null)
@@ -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(() => {
@@ -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).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 8fc65c6544..d0697b7a8a 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 2304b992c5..c8cfc53b02 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 d76019befe..f8650d5777 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 5e7d0c7401..eb9c35417e 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 f60a3f6a51..8c169e8f89 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 9c5e1dedc6..8daca70488 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=="],