diff --git a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx index c6a6b5a231..ed0ce3987d 100644 --- a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx +++ b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx @@ -8,6 +8,7 @@ const mockSyncInputFieldsDraft = vi.fn() const mockCloseEditor = vi.fn() const mockOpenEditor = vi.fn() const mockReset = vi.fn() +const mockSetFields = vi.fn() const mockSetInputPanelOpen = vi.fn() const mockSetPublishMenuOpen = vi.fn() const mockToggleInputPanel = vi.fn() @@ -38,33 +39,24 @@ const mockInspectVarsCrud = { invalidateConversationVarValues: vi.fn(), } let capturedHooksStore: Record | undefined +let snippetDetailStoreState: { + editingField: SnippetInputField | null + fields: SnippetInputField[] + isEditorOpen: boolean + isInputPanelOpen: boolean + isPublishMenuOpen: boolean + closeEditor: typeof mockCloseEditor + openEditor: typeof mockOpenEditor + reset: typeof mockReset + setFields: typeof mockSetFields + setInputPanelOpen: typeof mockSetInputPanelOpen + setPublishMenuOpen: typeof mockSetPublishMenuOpen + toggleInputPanel: typeof mockToggleInputPanel + togglePublishMenu: typeof mockTogglePublishMenu +} vi.mock('@/app/components/snippets/store', () => ({ - useSnippetDetailStore: (selector: (state: { - editingField: SnippetInputField | null - isEditorOpen: boolean - isInputPanelOpen: boolean - isPublishMenuOpen: boolean - closeEditor: typeof mockCloseEditor - openEditor: typeof mockOpenEditor - reset: typeof mockReset - setInputPanelOpen: typeof mockSetInputPanelOpen - setPublishMenuOpen: typeof mockSetPublishMenuOpen - toggleInputPanel: typeof mockToggleInputPanel - togglePublishMenu: typeof mockTogglePublishMenu - }) => unknown) => selector({ - editingField: null, - isEditorOpen: false, - isInputPanelOpen: true, - isPublishMenuOpen: false, - closeEditor: mockCloseEditor, - openEditor: mockOpenEditor, - reset: mockReset, - setInputPanelOpen: mockSetInputPanelOpen, - setPublishMenuOpen: mockSetPublishMenuOpen, - toggleInputPanel: mockToggleInputPanel, - togglePublishMenu: mockTogglePublishMenu, - }), + useSnippetDetailStore: (selector: (state: typeof snippetDetailStoreState) => unknown) => selector(snippetDetailStoreState), })) vi.mock('@/service/use-snippet-workflows', () => ({ @@ -216,6 +208,21 @@ describe('SnippetMain', () => { mockSyncInputFieldsDraft.mockResolvedValue(undefined) mockPublishSnippetMutateAsync.mockResolvedValue(undefined) capturedHooksStore = undefined + snippetDetailStoreState = { + editingField: null, + fields: [...payload.inputFields], + isEditorOpen: false, + isInputPanelOpen: true, + isPublishMenuOpen: false, + closeEditor: mockCloseEditor, + openEditor: mockOpenEditor, + reset: mockReset, + setFields: mockSetFields, + setInputPanelOpen: mockSetInputPanelOpen, + setPublishMenuOpen: mockSetPublishMenuOpen, + toggleInputPanel: mockToggleInputPanel, + togglePublishMenu: mockTogglePublishMenu, + } }) describe('Input Fields Sync', () => { diff --git a/web/app/components/snippets/components/hooks/__tests__/use-snippet-input-field-actions.spec.ts b/web/app/components/snippets/components/hooks/__tests__/use-snippet-input-field-actions.spec.ts index ae9819e464..f8f9f1daba 100644 --- a/web/app/components/snippets/components/hooks/__tests__/use-snippet-input-field-actions.spec.ts +++ b/web/app/components/snippets/components/hooks/__tests__/use-snippet-input-field-actions.spec.ts @@ -7,15 +7,18 @@ import { useSnippetInputFieldActions } from '../use-snippet-input-field-actions' const mockSyncInputFieldsDraft = vi.fn() const mockCloseEditor = vi.fn() const mockOpenEditor = vi.fn() +const mockSetFields = vi.fn() const mockSetInputPanelOpen = vi.fn() const mockToggleInputPanel = vi.fn() let snippetDetailStoreState: { editingField: SnippetInputField | null + fields: SnippetInputField[] isEditorOpen: boolean isInputPanelOpen: boolean closeEditor: typeof mockCloseEditor openEditor: typeof mockOpenEditor + setFields: typeof mockSetFields setInputPanelOpen: typeof mockSetInputPanelOpen toggleInputPanel: typeof mockToggleInputPanel } @@ -49,37 +52,42 @@ describe('useSnippetInputFieldActions', () => { vi.clearAllMocks() snippetDetailStoreState = { editingField: null, + fields: [], isEditorOpen: false, isInputPanelOpen: true, closeEditor: mockCloseEditor, openEditor: mockOpenEditor, + setFields: mockSetFields, setInputPanelOpen: mockSetInputPanelOpen, toggleInputPanel: mockToggleInputPanel, } + mockSetFields.mockImplementation((fields: SnippetInputField[]) => { + snippetDetailStoreState.fields = fields + }) mockSyncInputFieldsDraft.mockResolvedValue(undefined) }) describe('Field sync', () => { it('should remove a field and sync the draft', () => { + snippetDetailStoreState.fields = [createField()] const { result } = renderHook(() => useSnippetInputFieldActions({ snippetId: 'snippet-1', - initialFields: [createField()], })) act(() => { result.current.handleRemoveField(0) }) - expect(result.current.fields).toEqual([]) + expect(mockSetFields).toHaveBeenCalledWith([]) expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([], { onRefresh: expect.any(Function), }) }) it('should append a new field and close the editor after syncing', () => { + snippetDetailStoreState.fields = [createField()] const { result } = renderHook(() => useSnippetInputFieldActions({ snippetId: 'snippet-1', - initialFields: [createField()], })) act(() => { @@ -89,7 +97,7 @@ describe('useSnippetInputFieldActions', () => { })) }) - expect(result.current.fields).toEqual([ + expect(mockSetFields).toHaveBeenCalledWith([ createField(), createField({ label: 'Topic', @@ -109,9 +117,9 @@ describe('useSnippetInputFieldActions', () => { }) it('should reject duplicated variables without syncing', () => { + snippetDetailStoreState.fields = [createField()] const { result } = renderHook(() => useSnippetInputFieldActions({ snippetId: 'snippet-1', - initialFields: [createField()], })) act(() => { @@ -124,15 +132,15 @@ describe('useSnippetInputFieldActions', () => { expect(toast.error).toHaveBeenCalledWith('datasetPipeline.inputFieldPanel.error.variableDuplicate') expect(mockSyncInputFieldsDraft).not.toHaveBeenCalled() expect(mockCloseEditor).not.toHaveBeenCalled() - expect(result.current.fields).toEqual([createField()]) + expect(mockSetFields).not.toHaveBeenCalled() }) }) describe('Panel actions', () => { it('should close the editor before toggling the input panel when the panel is open', () => { + snippetDetailStoreState.fields = [createField()] const { result } = renderHook(() => useSnippetInputFieldActions({ snippetId: 'snippet-1', - initialFields: [createField()], })) act(() => { @@ -144,9 +152,9 @@ describe('useSnippetInputFieldActions', () => { }) it('should close the input panel and clear the editor state', () => { + snippetDetailStoreState.fields = [createField()] const { result } = renderHook(() => useSnippetInputFieldActions({ snippetId: 'snippet-1', - initialFields: [createField()], })) act(() => { diff --git a/web/app/components/snippets/components/hooks/use-snippet-input-field-actions.ts b/web/app/components/snippets/components/hooks/use-snippet-input-field-actions.ts index 738c9cc646..503949004a 100644 --- a/web/app/components/snippets/components/hooks/use-snippet-input-field-actions.ts +++ b/web/app/components/snippets/components/hooks/use-snippet-input-field-actions.ts @@ -1,5 +1,5 @@ import type { SnippetInputField } from '@/models/snippet' -import { useCallback, useState } from 'react' +import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import { toast } from '@/app/components/base/ui/toast' @@ -8,37 +8,38 @@ import { useSnippetDetailStore } from '../../store' type UseSnippetInputFieldActionsOptions = { snippetId: string - initialFields: SnippetInputField[] } export const useSnippetInputFieldActions = ({ snippetId, - initialFields, }: UseSnippetInputFieldActionsOptions) => { const { t } = useTranslation('snippet') - const [fields, setFields] = useState(initialFields) const { syncInputFieldsDraft } = useNodesSyncDraft(snippetId) const { editingField, + fields, isEditorOpen, isInputPanelOpen, closeEditor, openEditor, + setFields, setInputPanelOpen, toggleInputPanel, } = useSnippetDetailStore(useShallow(state => ({ editingField: state.editingField, + fields: state.fields, isEditorOpen: state.isEditorOpen, isInputPanelOpen: state.isInputPanelOpen, closeEditor: state.closeEditor, openEditor: state.openEditor, + setFields: state.setFields, setInputPanelOpen: state.setInputPanelOpen, toggleInputPanel: state.toggleInputPanel, }))) const handleSortChange = useCallback((newFields: SnippetInputField[]) => { setFields(newFields) - }, []) + }, [setFields]) const handleRemoveField = useCallback((index: number) => { const nextFields = fields.filter((_, currentIndex) => currentIndex !== index) @@ -46,7 +47,7 @@ export const useSnippetInputFieldActions = ({ void syncInputFieldsDraft(nextFields, { onRefresh: setFields, }) - }, [fields, syncInputFieldsDraft]) + }, [fields, setFields, syncInputFieldsDraft]) const handleSubmitField = useCallback((field: SnippetInputField) => { const originalVariable = editingField?.variable @@ -66,7 +67,7 @@ export const useSnippetInputFieldActions = ({ onRefresh: setFields, }) closeEditor() - }, [closeEditor, editingField?.variable, fields, syncInputFieldsDraft, t]) + }, [closeEditor, editingField?.variable, fields, setFields, syncInputFieldsDraft, t]) const handleToggleInputPanel = useCallback(() => { if (isInputPanelOpen) diff --git a/web/app/components/snippets/components/snippet-main.tsx b/web/app/components/snippets/components/snippet-main.tsx index f65ec8c6f8..6dd75ac426 100644 --- a/web/app/components/snippets/components/snippet-main.tsx +++ b/web/app/components/snippets/components/snippet-main.tsx @@ -6,6 +6,7 @@ import { useEffect, useMemo, } from 'react' +import { useShallow } from 'zustand/react/shallow' import { WorkflowWithInnerContext } from '@/app/components/workflow' import { useAvailableNodesMetaData } from '@/app/components/workflow-app/hooks' import { useSetWorkflowVarsWithValue } from '@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars' @@ -85,7 +86,13 @@ const SnippetMain = ({ nodesMap, } }, [workflowAvailableNodesMetaData]) - const reset = useSnippetDetailStore(state => state.reset) + const { + reset, + setFields, + } = useSnippetDetailStore(useShallow(state => ({ + reset: state.reset, + setFields: state.setFields, + }))) const { editingField, fields, @@ -100,7 +107,6 @@ const SnippetMain = ({ handleToggleInputPanel, } = useSnippetInputFieldActions({ snippetId, - initialFields: payload.inputFields, }) const { handlePublish, @@ -122,6 +128,10 @@ const SnippetMain = ({ reset() }, [reset, snippetId]) + useEffect(() => { + setFields(payload.inputFields) + }, [payload.inputFields, setFields, snippetId]) + const hooksStore = useMemo(() => { return { doSyncWorkflowDraft, diff --git a/web/app/components/snippets/hooks/__tests__/use-nodes-sync-draft.spec.ts b/web/app/components/snippets/hooks/__tests__/use-nodes-sync-draft.spec.ts new file mode 100644 index 0000000000..30c9a780fa --- /dev/null +++ b/web/app/components/snippets/hooks/__tests__/use-nodes-sync-draft.spec.ts @@ -0,0 +1,163 @@ +import type { SnippetInputField } from '@/models/snippet' +import { act, renderHook } from '@testing-library/react' +import { PipelineInputVarType } from '@/models/pipeline' +import { useSnippetDetailStore } from '../../store' +import { useNodesSyncDraft } from '../use-nodes-sync-draft' + +const mockGetNodes = vi.fn() +const mockGetNodesReadOnly = vi.fn() +const mockPostWithKeepalive = vi.fn() +const mockSyncDraftWorkflow = vi.fn() +const mockSetDraftUpdatedAt = vi.fn() +const mockSetSyncWorkflowDraftHash = vi.fn() + +let reactFlowState: { + getNodes: typeof mockGetNodes + edges: Array> + transform: [number, number, number] +} + +let workflowStoreState: { + syncWorkflowDraftHash: string | null + setDraftUpdatedAt: typeof mockSetDraftUpdatedAt + setSyncWorkflowDraftHash: typeof mockSetSyncWorkflowDraftHash +} + +vi.mock('reactflow', () => ({ + useStoreApi: () => ({ getState: () => reactFlowState }), +})) + +vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({ + useNodesReadOnly: () => ({ getNodesReadOnly: mockGetNodesReadOnly }), +})) + +vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({ + useSerialAsyncCallback: (fn: (...args: unknown[]) => Promise, checkFn?: () => boolean) => + (...args: unknown[]) => { + if (checkFn?.()) + return + + return fn(...args) + }, +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + getState: () => workflowStoreState, + }), +})) + +vi.mock('@/service/client', () => ({ + consoleClient: { + snippets: { + syncDraftWorkflow: (...args: unknown[]) => mockSyncDraftWorkflow(...args), + }, + }, +})) + +vi.mock('@/service/fetch', () => ({ + postWithKeepalive: (...args: unknown[]) => mockPostWithKeepalive(...args), +})) + +vi.mock('@/config', () => ({ API_PREFIX: '/api' })) + +vi.mock('../use-snippet-refresh-draft', () => ({ + useSnippetRefreshDraft: () => ({ + handleRefreshWorkflowDraft: vi.fn(), + }), +})) + +const createInputField = (variable: string): SnippetInputField => ({ + type: PipelineInputVarType.textInput, + label: variable, + variable, + required: false, +}) + +describe('snippet/use-nodes-sync-draft', () => { + beforeEach(() => { + vi.clearAllMocks() + reactFlowState = { + getNodes: mockGetNodes, + edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: { stable: true } }], + transform: [12, 24, 1.5], + } + workflowStoreState = { + syncWorkflowDraftHash: 'draft-hash', + setDraftUpdatedAt: mockSetDraftUpdatedAt, + setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash, + } + mockGetNodesReadOnly.mockReturnValue(false) + mockGetNodes.mockReturnValue([ + { id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Start', _temp: 'drop' } }, + ]) + mockSyncDraftWorkflow.mockResolvedValue({ + hash: 'next-hash', + updated_at: 123, + }) + useSnippetDetailStore.setState({ + fields: [createInputField('topic')], + }) + }) + + it('should include current input_fields when syncing the draft graph', async () => { + const { result } = renderHook(() => useNodesSyncDraft('snippet-1')) + + await act(async () => { + await result.current.doSyncWorkflowDraft() + }) + + expect(mockSyncDraftWorkflow).toHaveBeenCalledWith({ + params: { snippetId: 'snippet-1' }, + body: { + graph: { + nodes: [{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Start' } }], + edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: { stable: true } }], + viewport: { x: 12, y: 24, zoom: 1.5 }, + }, + input_fields: [createInputField('topic')], + hash: 'draft-hash', + }, + }) + }) + + it('should include the latest graph when syncing input fields', async () => { + const { result } = renderHook(() => useNodesSyncDraft('snippet-1')) + const nextFields = [createInputField('summary')] + + await act(async () => { + await result.current.syncInputFieldsDraft(nextFields) + }) + + expect(mockSyncDraftWorkflow).toHaveBeenCalledWith({ + params: { snippetId: 'snippet-1' }, + body: { + graph: { + nodes: [{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Start' } }], + edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: { stable: true } }], + viewport: { x: 12, y: 24, zoom: 1.5 }, + }, + input_fields: nextFields, + hash: 'draft-hash', + }, + }) + }) + + it('should send input_fields together with graph on page close', () => { + const { result } = renderHook(() => useNodesSyncDraft('snippet-1')) + + act(() => { + result.current.syncWorkflowDraftWhenPageClose() + }) + + expect(mockPostWithKeepalive).toHaveBeenCalledWith('/api/snippets/snippet-1/workflows/draft', { + graph: { + nodes: [{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Start' } }], + edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: { stable: true } }], + viewport: { x: 12, y: 24, zoom: 1.5 }, + }, + input_fields: [createInputField('topic')], + hash: 'draft-hash', + }) + }) +}) diff --git a/web/app/components/snippets/hooks/use-nodes-sync-draft.ts b/web/app/components/snippets/hooks/use-nodes-sync-draft.ts index 169f1e6e9a..1c3efb5647 100644 --- a/web/app/components/snippets/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/snippets/hooks/use-nodes-sync-draft.ts @@ -10,6 +10,7 @@ import { useWorkflowStore } from '@/app/components/workflow/store' import { API_PREFIX } from '@/config' import { consoleClient } from '@/service/client' import { postWithKeepalive } from '@/service/fetch' +import { useSnippetDetailStore } from '../store' import { useSnippetRefreshDraft } from './use-snippet-refresh-draft' const isSyncConflictError = (error: unknown): error is { bodyUsed: boolean, json: () => Promise<{ code?: string }> } => { @@ -30,7 +31,13 @@ export const useNodesSyncDraft = (snippetId: string) => { const { getNodesReadOnly } = useNodesReadOnly() const { handleRefreshWorkflowDraft } = useSnippetRefreshDraft(snippetId) - const getGraphSyncPayload = useCallback(() => { + const getInputFieldsSyncPayload = useCallback((inputFields?: SnippetInputField[]) => { + return { + input_fields: inputFields ?? useSnippetDetailStore.getState().fields, + } + }, []) + + const getDraftSyncPayload = useCallback((inputFields?: SnippetInputField[]) => { const { getNodes, edges, @@ -59,13 +66,14 @@ export const useNodesSyncDraft = (snippetId: string) => { }) return { + ...getInputFieldsSyncPayload(inputFields), graph: { nodes: producedNodes, edges: producedEdges, viewport: { x, y, zoom }, }, } - }, [snippetId, store]) + }, [getInputFieldsSyncPayload, snippetId, store]) const syncDraft = useCallback(async ( payload: Omit, @@ -116,34 +124,38 @@ export const useNodesSyncDraft = (snippetId: string) => { if (getNodesReadOnly()) return - const graphPayload = getGraphSyncPayload() - if (!graphPayload) + const draftPayload = getDraftSyncPayload() + if (!draftPayload) return const { syncWorkflowDraftHash } = workflowStore.getState() postWithKeepalive(`${API_PREFIX}/snippets/${snippetId}/workflows/draft`, { - ...graphPayload, + ...draftPayload, hash: syncWorkflowDraftHash, }) - }, [getGraphSyncPayload, getNodesReadOnly, snippetId, workflowStore]) + }, [getDraftSyncPayload, getNodesReadOnly, snippetId, workflowStore]) const performSync = useCallback(async ( notRefreshWhenSyncError?: boolean, callback?: SyncDraftCallback, ) => { - const graphPayload = getGraphSyncPayload() - if (!graphPayload) + const draftPayload = getDraftSyncPayload() + if (!draftPayload) return - await syncDraft(graphPayload, notRefreshWhenSyncError, callback) - }, [getGraphSyncPayload, syncDraft]) + await syncDraft(draftPayload, notRefreshWhenSyncError, callback) + }, [getDraftSyncPayload, syncDraft]) const performInputFieldsSync = useCallback(async ( inputFields: SnippetInputField[], callback?: SyncInputFieldsDraftCallback, ) => { + const draftPayload = getDraftSyncPayload(inputFields) + if (!draftPayload) + return + await syncDraft( - { input_fields: inputFields }, + draftPayload, false, callback, (draftWorkflow) => { @@ -153,7 +165,7 @@ export const useNodesSyncDraft = (snippetId: string) => { callback?.onRefresh?.(refreshedInputFields) }, ) - }, [syncDraft]) + }, [getDraftSyncPayload, syncDraft]) const doSyncWorkflowDraft = useSerialAsyncCallback(performSync, getNodesReadOnly) const syncInputFieldsDraft = useSerialAsyncCallback(performInputFieldsSync) diff --git a/web/app/components/snippets/hooks/use-snippet-refresh-draft.ts b/web/app/components/snippets/hooks/use-snippet-refresh-draft.ts index 270db76a00..33420911de 100644 --- a/web/app/components/snippets/hooks/use-snippet-refresh-draft.ts +++ b/web/app/components/snippets/hooks/use-snippet-refresh-draft.ts @@ -1,9 +1,11 @@ import type { WorkflowDataUpdater } from '@/app/components/workflow/types' +import type { SnippetInputField } from '@/models/snippet' import type { SnippetWorkflow } from '@/types/snippet' import { useCallback } from 'react' import { useWorkflowUpdate } from '@/app/components/workflow/hooks' import { useWorkflowStore } from '@/app/components/workflow/store' import { consoleClient } from '@/service/client' +import { useSnippetDetailStore } from '../store' export const useSnippetRefreshDraft = (snippetId: string) => { const workflowStore = useWorkflowStore() @@ -23,12 +25,19 @@ export const useSnippetRefreshDraft = (snippetId: string) => { consoleClient.snippets.draftWorkflow({ params: { snippetId }, }).then((response) => { + const inputFields = Array.isArray(response.input_fields) + ? response.input_fields as SnippetInputField[] + : [] + handleUpdateWorkflowCanvas({ ...response.graph, nodes: response.graph?.nodes || [], edges: response.graph?.edges || [], viewport: response.graph?.viewport || { x: 0, y: 0, zoom: 1 }, } as WorkflowDataUpdater) + useSnippetDetailStore.setState({ + fields: inputFields, + }) setSyncWorkflowDraftHash(response.hash) setDraftUpdatedAt(response.updated_at) onSuccess?.(response) diff --git a/web/app/components/snippets/store/index.ts b/web/app/components/snippets/store/index.ts index c0c86d6aa2..4dcd78c046 100644 --- a/web/app/components/snippets/store/index.ts +++ b/web/app/components/snippets/store/index.ts @@ -5,12 +5,14 @@ import { create } from 'zustand' type SnippetDetailUIState = { activeSection: SnippetSection + fields: SnippetInputField[] isInputPanelOpen: boolean isPublishMenuOpen: boolean isPreviewMode: boolean isEditorOpen: boolean editingField: SnippetInputField | null setActiveSection: (section: SnippetSection) => void + setFields: (fields: SnippetInputField[]) => void setInputPanelOpen: (value: boolean) => void toggleInputPanel: () => void setPublishMenuOpen: (value: boolean) => void @@ -23,6 +25,7 @@ type SnippetDetailUIState = { const initialState = { activeSection: 'orchestrate' as SnippetSection, + fields: [] as SnippetInputField[], isInputPanelOpen: false, isPublishMenuOpen: false, isPreviewMode: false, @@ -33,6 +36,7 @@ const initialState = { export const useSnippetDetailStore = create(set => ({ ...initialState, setActiveSection: activeSection => set({ activeSection }), + setFields: fields => set({ fields }), setInputPanelOpen: isInputPanelOpen => set({ isInputPanelOpen }), toggleInputPanel: () => set(state => ({ isInputPanelOpen: !state.isInputPanelOpen, isPublishMenuOpen: false })), setPublishMenuOpen: isPublishMenuOpen => set({ isPublishMenuOpen }),