From 0e22b45ff44b6e5655dc390a2a69e48fc3d29b43 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 22:33:48 -0700 Subject: [PATCH] fix(copilot): scope panel chat drafts per chat The workflow panel's copilot draft key was scoped by workspace + workflow only. A draft is cleared on submit and never on chat switch, and the scope key is what remounts the input (`key={draftScopeKey}` in mothership-chat), so selecting a different copilot chat in the same workflow carried the previous chat's typed text, contexts, and file attachments into it. Key the draft on the selected chat as well, matching the home chat. Two consequences of per-chat keys handled here: - deleting a chat now prunes its draft, which would otherwise be unreachable in persisted storage forever - drafts persisted under the old workflow-only key are dropped by a store migration rather than left as unreadable entries --- .../w/[workflowId]/components/panel/panel.tsx | 29 +++++++++++--- .../stores/mothership-drafts/store.test.ts | 39 +++++++++++++++++++ apps/sim/stores/mothership-drafts/store.ts | 29 ++++++++++++++ 3 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 apps/sim/stores/mothership-drafts/store.test.ts 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 }), } ),