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 ad17691f1cf..2876a06a44a 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 @@ -84,6 +84,7 @@ import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useChatStore } from '@/stores/chat/store' +import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store' import type { ChatContext, PanelTab } from '@/stores/panel' import { usePanelStore } from '@/stores/panel' import { useVariablesModalStore } from '@/stores/variables/modal' @@ -97,6 +98,22 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('Panel') const EMPTY_COPILOT_CHATS: readonly CopilotChatListItem[] = [] + +/** + * Builds the persisted draft key for a workflow-copilot chat. + * + * Scoped per chat, not per workflow: a draft is cleared only on submit, so a + * workflow-wide key carries one chat's typed text, contexts, and attachments + * into the next chat selected. The workflow segment stays so each workflow + * keeps its own unselected-chat (`new`) draft. + */ +function copilotDraftKey( + workspaceId: string, + workflowId: string | undefined, + chatId: string | undefined +): string | undefined { + return workflowId ? `${workspaceId}:workflow-copilot:${workflowId}:${chatId ?? 'new'}` : undefined +} /** * Panel component with resizable width and tab navigation that persists across page refreshes. * @@ -274,6 +291,9 @@ export const Panel = memo(function Panel() { activeWorkflowId ?? undefined ) + const copilotDraftWorkflowId = activeWorkflowId ?? routeWorkflowId + const copilotDraftScopeKey = copilotDraftKey(workspaceId, copilotDraftWorkflowId, copilotChatId) + const { data: copilotChatList = EMPTY_COPILOT_CHATS } = useCopilotChats( isCopilotTabAvailable ? (activeWorkflowId ?? undefined) : undefined ) @@ -332,13 +352,16 @@ export const Panel = memo(function Panel() { if (copilotChatId === chatId) { setCopilotChatId(undefined) } + // The draft store is persisted, so an unpruned key survives forever. + const draftKey = copilotDraftKey(workspaceId, copilotDraftWorkflowId, chatId) + if (draftKey) useMothershipDraftsStore.getState().clearDraft(draftKey) loadCopilotChats() }) .catch((err) => { logger.error('Failed to delete copilot chat', { error: toError(err).message, chatId }) }) }, - [copilotChatId, loadCopilotChats, setCopilotChatId] + [copilotChatId, loadCopilotChats, setCopilotChatId, workspaceId, copilotDraftWorkflowId] ) const handleCopilotToolResult = useCallback( @@ -398,10 +421,6 @@ export const Panel = memo(function Panel() { }, }) ) - const copilotDraftWorkflowId = activeWorkflowId ?? routeWorkflowId - const copilotDraftScopeKey = copilotDraftWorkflowId - ? `${workspaceId}:workflow-copilot:${copilotDraftWorkflowId}` - : undefined const handleCopilotNewChat = useCallback(() => { if (!activeWorkflowId || !workspaceId) return diff --git a/apps/sim/stores/mothership-drafts/store.test.ts b/apps/sim/stores/mothership-drafts/store.test.ts new file mode 100644 index 00000000000..0de577767a2 --- /dev/null +++ b/apps/sim/stores/mothership-drafts/store.test.ts @@ -0,0 +1,39 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { dropLegacyWorkflowCopilotDrafts } from '@/stores/mothership-drafts/store' + +const payload = { text: 'unsent' } + +describe('dropLegacyWorkflowCopilotDrafts', () => { + it('drops workflow-only copilot keys that no surface reads anymore', () => { + const { drafts } = dropLegacyWorkflowCopilotDrafts({ + drafts: { 'ws-1:workflow-copilot:wf-1': payload }, + }) + + expect(drafts).toEqual({}) + }) + + it('keeps home drafts, whose key shape did not change', () => { + const { drafts } = dropLegacyWorkflowCopilotDrafts({ + drafts: { 'ws-1:chat-1': payload, 'ws-1:new': payload }, + }) + + expect(drafts).toEqual({ 'ws-1:chat-1': payload, 'ws-1:new': payload }) + }) + + it('keeps per-chat copilot keys, including the unselected-chat slot', () => { + const drafts = { + 'ws-1:workflow-copilot:wf-1:chat-1': payload, + 'ws-1:workflow-copilot:wf-1:new': payload, + } + + expect(dropLegacyWorkflowCopilotDrafts({ drafts }).drafts).toEqual(drafts) + }) + + it('returns an empty map when nothing was persisted', () => { + expect(dropLegacyWorkflowCopilotDrafts(null).drafts).toEqual({}) + expect(dropLegacyWorkflowCopilotDrafts({}).drafts).toEqual({}) + }) +}) diff --git a/apps/sim/stores/mothership-drafts/store.ts b/apps/sim/stores/mothership-drafts/store.ts index 5e98334b69c..da0b94e0bfe 100644 --- a/apps/sim/stores/mothership-drafts/store.ts +++ b/apps/sim/stores/mothership-drafts/store.ts @@ -9,12 +9,38 @@ export interface DraftPayload { contexts?: ChatContext[] } +/** + * Draft keys are owned by the surface that renders the input, not by this + * store. Two shapes exist: `:` for the home chat and + * `:workflow-copilot::` for the workflow + * panel. + */ interface MothershipDraftsState { drafts: Record setDraft: (key: string, payload: DraftPayload) => void clearDraft: (key: string) => void } +const LEGACY_WORKFLOW_COPILOT_KEY = /^[^:]+:workflow-copilot:[^:]+$/ + +/** + * v0 keyed workflow-panel drafts by workflow alone. Those entries are no longer + * readable by any surface, and nothing prunes a key that is never written + * again, so drop them once rather than leave them in storage forever. Home + * drafts are untouched — their key shape did not change. + */ +export function dropLegacyWorkflowCopilotDrafts(persistedState: unknown): { + drafts: Record +} { + const drafts = (persistedState as MothershipDraftsState | null)?.drafts + if (!drafts) return { drafts: {} } + const kept: Record = {} + for (const [key, payload] of Object.entries(drafts)) { + if (!LEGACY_WORKFLOW_COPILOT_KEY.test(key)) kept[key] = payload + } + return { drafts: kept } +} + function isEmpty(payload: DraftPayload): boolean { return !payload.text && !payload.fileAttachments?.length && !payload.contexts?.length } @@ -42,6 +68,9 @@ export const useMothershipDraftsStore = create()( }), { name: 'mothership-drafts:v1', + version: 1, + migrate: (persistedState, version) => + (version ?? 0) < 1 ? dropLegacyWorkflowCopilotDrafts(persistedState) : persistedState, partialize: (state) => ({ drafts: state.drafts }), } ),