Skip to content

Commit 0e22b45

Browse files
committed
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
1 parent e906190 commit 0e22b45

3 files changed

Lines changed: 92 additions & 5 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
8484
import { usePermissionConfig } from '@/hooks/use-permission-config'
8585
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
8686
import { useChatStore } from '@/stores/chat/store'
87+
import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
8788
import type { ChatContext, PanelTab } from '@/stores/panel'
8889
import { usePanelStore } from '@/stores/panel'
8990
import { useVariablesModalStore } from '@/stores/variables/modal'
@@ -97,6 +98,22 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
9798

9899
const logger = createLogger('Panel')
99100
const EMPTY_COPILOT_CHATS: readonly CopilotChatListItem[] = []
101+
102+
/**
103+
* Builds the persisted draft key for a workflow-copilot chat.
104+
*
105+
* Scoped per chat, not per workflow: a draft is cleared only on submit, so a
106+
* workflow-wide key carries one chat's typed text, contexts, and attachments
107+
* into the next chat selected. The workflow segment stays so each workflow
108+
* keeps its own unselected-chat (`new`) draft.
109+
*/
110+
function copilotDraftKey(
111+
workspaceId: string,
112+
workflowId: string | undefined,
113+
chatId: string | undefined
114+
): string | undefined {
115+
return workflowId ? `${workspaceId}:workflow-copilot:${workflowId}:${chatId ?? 'new'}` : undefined
116+
}
100117
/**
101118
* Panel component with resizable width and tab navigation that persists across page refreshes.
102119
*
@@ -274,6 +291,9 @@ export const Panel = memo(function Panel() {
274291
activeWorkflowId ?? undefined
275292
)
276293

294+
const copilotDraftWorkflowId = activeWorkflowId ?? routeWorkflowId
295+
const copilotDraftScopeKey = copilotDraftKey(workspaceId, copilotDraftWorkflowId, copilotChatId)
296+
277297
const { data: copilotChatList = EMPTY_COPILOT_CHATS } = useCopilotChats(
278298
isCopilotTabAvailable ? (activeWorkflowId ?? undefined) : undefined
279299
)
@@ -332,13 +352,16 @@ export const Panel = memo(function Panel() {
332352
if (copilotChatId === chatId) {
333353
setCopilotChatId(undefined)
334354
}
355+
// The draft store is persisted, so an unpruned key survives forever.
356+
const draftKey = copilotDraftKey(workspaceId, copilotDraftWorkflowId, chatId)
357+
if (draftKey) useMothershipDraftsStore.getState().clearDraft(draftKey)
335358
loadCopilotChats()
336359
})
337360
.catch((err) => {
338361
logger.error('Failed to delete copilot chat', { error: toError(err).message, chatId })
339362
})
340363
},
341-
[copilotChatId, loadCopilotChats, setCopilotChatId]
364+
[copilotChatId, loadCopilotChats, setCopilotChatId, workspaceId, copilotDraftWorkflowId]
342365
)
343366

344367
const handleCopilotToolResult = useCallback(
@@ -398,10 +421,6 @@ export const Panel = memo(function Panel() {
398421
},
399422
})
400423
)
401-
const copilotDraftWorkflowId = activeWorkflowId ?? routeWorkflowId
402-
const copilotDraftScopeKey = copilotDraftWorkflowId
403-
? `${workspaceId}:workflow-copilot:${copilotDraftWorkflowId}`
404-
: undefined
405424

406425
const handleCopilotNewChat = useCallback(() => {
407426
if (!activeWorkflowId || !workspaceId) return
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { dropLegacyWorkflowCopilotDrafts } from '@/stores/mothership-drafts/store'
6+
7+
const payload = { text: 'unsent' }
8+
9+
describe('dropLegacyWorkflowCopilotDrafts', () => {
10+
it('drops workflow-only copilot keys that no surface reads anymore', () => {
11+
const { drafts } = dropLegacyWorkflowCopilotDrafts({
12+
drafts: { 'ws-1:workflow-copilot:wf-1': payload },
13+
})
14+
15+
expect(drafts).toEqual({})
16+
})
17+
18+
it('keeps home drafts, whose key shape did not change', () => {
19+
const { drafts } = dropLegacyWorkflowCopilotDrafts({
20+
drafts: { 'ws-1:chat-1': payload, 'ws-1:new': payload },
21+
})
22+
23+
expect(drafts).toEqual({ 'ws-1:chat-1': payload, 'ws-1:new': payload })
24+
})
25+
26+
it('keeps per-chat copilot keys, including the unselected-chat slot', () => {
27+
const drafts = {
28+
'ws-1:workflow-copilot:wf-1:chat-1': payload,
29+
'ws-1:workflow-copilot:wf-1:new': payload,
30+
}
31+
32+
expect(dropLegacyWorkflowCopilotDrafts({ drafts }).drafts).toEqual(drafts)
33+
})
34+
35+
it('returns an empty map when nothing was persisted', () => {
36+
expect(dropLegacyWorkflowCopilotDrafts(null).drafts).toEqual({})
37+
expect(dropLegacyWorkflowCopilotDrafts({}).drafts).toEqual({})
38+
})
39+
})

apps/sim/stores/mothership-drafts/store.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,38 @@ export interface DraftPayload {
99
contexts?: ChatContext[]
1010
}
1111

12+
/**
13+
* Draft keys are owned by the surface that renders the input, not by this
14+
* store. Two shapes exist: `<workspaceId>:<chatId|'new'>` for the home chat and
15+
* `<workspaceId>:workflow-copilot:<workflowId>:<chatId|'new'>` for the workflow
16+
* panel.
17+
*/
1218
interface MothershipDraftsState {
1319
drafts: Record<string, DraftPayload>
1420
setDraft: (key: string, payload: DraftPayload) => void
1521
clearDraft: (key: string) => void
1622
}
1723

24+
const LEGACY_WORKFLOW_COPILOT_KEY = /^[^:]+:workflow-copilot:[^:]+$/
25+
26+
/**
27+
* v0 keyed workflow-panel drafts by workflow alone. Those entries are no longer
28+
* readable by any surface, and nothing prunes a key that is never written
29+
* again, so drop them once rather than leave them in storage forever. Home
30+
* drafts are untouched — their key shape did not change.
31+
*/
32+
export function dropLegacyWorkflowCopilotDrafts(persistedState: unknown): {
33+
drafts: Record<string, DraftPayload>
34+
} {
35+
const drafts = (persistedState as MothershipDraftsState | null)?.drafts
36+
if (!drafts) return { drafts: {} }
37+
const kept: Record<string, DraftPayload> = {}
38+
for (const [key, payload] of Object.entries(drafts)) {
39+
if (!LEGACY_WORKFLOW_COPILOT_KEY.test(key)) kept[key] = payload
40+
}
41+
return { drafts: kept }
42+
}
43+
1844
function isEmpty(payload: DraftPayload): boolean {
1945
return !payload.text && !payload.fileAttachments?.length && !payload.contexts?.length
2046
}
@@ -42,6 +68,9 @@ export const useMothershipDraftsStore = create<MothershipDraftsState>()(
4268
}),
4369
{
4470
name: 'mothership-drafts:v1',
71+
version: 1,
72+
migrate: (persistedState, version) =>
73+
(version ?? 0) < 1 ? dropLegacyWorkflowCopilotDrafts(persistedState) : persistedState,
4574
partialize: (state) => ({ drafts: state.drafts }),
4675
}
4776
),

0 commit comments

Comments
 (0)