Skip to content

Commit 18a559b

Browse files
fix(copilot): use different chats for different workflows (#4324)
* fix(copilot): use different chats for different workflows * remove use effect * trim comments
1 parent feeb0a8 commit 18a559b

4 files changed

Lines changed: 158 additions & 51 deletions

File tree

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

Lines changed: 53 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
'use client'
22

3-
import { memo, useCallback, useEffect, useRef, useState } from 'react'
3+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
55
import { toError } from '@sim/utils/errors'
6+
import { useQueryClient } from '@tanstack/react-query'
67
import { History, Plus, Square } from 'lucide-react'
78
import { useParams, useRouter } from 'next/navigation'
89
import { usePostHog } from 'posthog-js/react'
@@ -59,6 +60,12 @@ import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]
5960
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
6061
import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
6162
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
63+
import { useCopilotChatSelection } from '@/hooks/queries/copilot-chat-selection'
64+
import {
65+
type CopilotChatListItem,
66+
copilotChatsKeys,
67+
useCopilotChats,
68+
} from '@/hooks/queries/copilot-chats'
6269
import { useDuplicateWorkflowMutation, useWorkflowMap } from '@/hooks/queries/workflows'
6370
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
6471
import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -77,6 +84,7 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
7784
import type { WorkflowState } from '@/stores/workflows/workflow/types'
7885

7986
const logger = createLogger('Panel')
87+
const EMPTY_COPILOT_CHATS: readonly CopilotChatListItem[] = []
8088
/**
8189
* Panel component with resizable width and tab navigation that persists across page refreshes.
8290
*
@@ -222,63 +230,59 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
222230
const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null
223231
const { isSnapshotView } = useCurrentWorkflow()
224232

225-
const [copilotChatId, setCopilotChatId] = useState<string | undefined>(undefined)
226-
const [copilotChatTitle, setCopilotChatTitle] = useState<string | null>(null)
227-
const [copilotChatList, setCopilotChatList] = useState<
228-
{ id: string; title: string | null; updatedAt: string; activeStreamId: string | null }[]
229-
>([])
233+
const { chatId: copilotChatId, setChatId: setCopilotChatId } = useCopilotChatSelection(
234+
activeWorkflowId ?? undefined
235+
)
236+
237+
const { data: copilotChatList = EMPTY_COPILOT_CHATS } = useCopilotChats(
238+
activeWorkflowId ?? undefined
239+
)
230240
const [isCopilotHistoryOpen, setIsCopilotHistoryOpen] = useState(false)
231241

232-
const copilotChatIdRef = useRef(copilotChatId)
233-
copilotChatIdRef.current = copilotChatId
234-
const copilotInitialLoadDoneRef = useRef(false)
242+
const copilotChatTitle = useMemo(
243+
() =>
244+
copilotChatId ? (copilotChatList.find((c) => c.id === copilotChatId)?.title ?? null) : null,
245+
[copilotChatId, copilotChatList]
246+
)
235247

248+
const queryClient = useQueryClient()
236249
const loadCopilotChats = useCallback(() => {
237250
if (!activeWorkflowId) return
238-
fetch('/api/copilot/chats')
239-
.then((res) => (res.ok ? res.json() : { chats: [] }))
240-
.then((data) => {
241-
const allChats = Array.isArray(data?.chats) ? data.chats : []
242-
const filtered = allChats.filter(
243-
(c: { workflowId?: string }) => c.workflowId === activeWorkflowId
244-
) as Array<{
245-
id: string
246-
title: string | null
247-
updatedAt: string
248-
activeStreamId: string | null
249-
}>
250-
setCopilotChatList(filtered)
251-
252-
const currentId = copilotChatIdRef.current
253-
if (currentId) {
254-
const match = filtered.find((c: { id: string }) => c.id === currentId)
255-
if (match?.title) setCopilotChatTitle(match.title)
256-
}
257-
258-
if (!copilotInitialLoadDoneRef.current && !currentId && filtered.length > 0) {
259-
copilotInitialLoadDoneRef.current = true
260-
setCopilotChatId(filtered[0].id)
261-
setCopilotChatTitle(filtered[0].title)
262-
}
263-
copilotInitialLoadDoneRef.current = true
264-
})
265-
.catch(() => {})
266-
}, [activeWorkflowId])
251+
queryClient.invalidateQueries({ queryKey: copilotChatsKeys.list(activeWorkflowId) })
252+
}, [activeWorkflowId, queryClient])
267253

254+
// Auto-select most recent on first list arrival per workflow, and drop a
255+
// selection that no longer matches anything in the current list (e.g. the
256+
// chat was deleted in another tab).
257+
const autoSelectAttemptedForRef = useRef<Set<string>>(new Set())
268258
useEffect(() => {
269-
copilotInitialLoadDoneRef.current = false
270-
loadCopilotChats()
271-
}, [loadCopilotChats])
259+
if (!activeWorkflowId) return
260+
261+
if (copilotChatId && !copilotChatList.find((c) => c.id === copilotChatId)) {
262+
setCopilotChatId(undefined)
263+
return
264+
}
265+
266+
if (copilotChatId) return
267+
if (autoSelectAttemptedForRef.current.has(activeWorkflowId)) return
268+
autoSelectAttemptedForRef.current.add(activeWorkflowId)
269+
270+
if (copilotChatList.length > 0) {
271+
setCopilotChatId(copilotChatList[0].id)
272+
}
273+
}, [copilotChatList, copilotChatId, activeWorkflowId, setCopilotChatId])
272274

273275
useEffect(() => {
274276
posthogRef.current = posthog
275277
}, [posthog])
276278

277-
const handleCopilotSelectChat = useCallback((chat: { id: string; title: string | null }) => {
278-
setCopilotChatId(chat.id)
279-
setCopilotChatTitle(chat.title)
280-
setIsCopilotHistoryOpen(false)
281-
}, [])
279+
const handleCopilotSelectChat = useCallback(
280+
(chat: { id: string; title: string | null }) => {
281+
setCopilotChatId(chat.id)
282+
setIsCopilotHistoryOpen(false)
283+
},
284+
[setCopilotChatId]
285+
)
282286

283287
const handleCopilotDeleteChat = useCallback(
284288
(chatId: string) => {
@@ -290,13 +294,12 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
290294
.then(() => {
291295
if (copilotChatId === chatId) {
292296
setCopilotChatId(undefined)
293-
setCopilotChatTitle(null)
294297
}
295298
loadCopilotChats()
296299
})
297300
.catch(() => {})
298301
},
299-
[copilotChatId, loadCopilotChats]
302+
[copilotChatId, loadCopilotChats, setCopilotChatId]
300303
)
301304

302305
const handleCopilotToolResult = useCallback(
@@ -361,14 +364,13 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
361364
.then((data: { id?: string }) => {
362365
if (data?.id) {
363366
setCopilotChatId(data.id)
364-
setCopilotChatTitle(null)
365367
loadCopilotChats()
366368
}
367369
})
368370
.catch((err) => {
369371
logger.error('Failed to create copilot chat', err)
370372
})
371-
}, [activeWorkflowId, workspaceId, loadCopilotChats])
373+
}, [activeWorkflowId, workspaceId, loadCopilotChats, setCopilotChatId])
372374

373375
const prevResolvedRef = useRef<string | undefined>(undefined)
374376
useEffect(() => {
@@ -383,7 +385,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
383385
} else {
384386
prevResolvedRef.current = copilotResolvedChatId
385387
}
386-
}, [copilotResolvedChatId, copilotChatId, loadCopilotChats])
388+
}, [copilotResolvedChatId, copilotChatId, loadCopilotChats, setCopilotChatId])
387389

388390
const wasCopilotSendingRef = useRef(false)
389391
useEffect(() => {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useCallback } from 'react'
2+
import { skipToken, useQuery, useQueryClient } from '@tanstack/react-query'
3+
4+
export const copilotChatSelectionKeys = {
5+
all: ['copilot-chat-selection'] as const,
6+
workflows: () => [...copilotChatSelectionKeys.all, 'workflow'] as const,
7+
workflow: (workflowId?: string) =>
8+
[...copilotChatSelectionKeys.workflows(), workflowId ?? ''] as const,
9+
}
10+
11+
/**
12+
* Reactive per-workflow copilot chat selection. Values are written via the
13+
* returned setter; queryFn is `skipToken` so the cache only ever holds
14+
* what setQueryData puts there.
15+
*/
16+
export function useCopilotChatSelection(workflowId?: string) {
17+
const queryClient = useQueryClient()
18+
19+
const { data: chatId } = useQuery<string | null>({
20+
queryKey: copilotChatSelectionKeys.workflow(workflowId),
21+
queryFn: skipToken,
22+
staleTime: Number.POSITIVE_INFINITY,
23+
initialData: null,
24+
})
25+
26+
const setChatId = useCallback(
27+
(next: string | undefined) => {
28+
if (!workflowId) return
29+
queryClient.setQueryData<string | null>(
30+
copilotChatSelectionKeys.workflow(workflowId),
31+
next ?? null
32+
)
33+
},
34+
[workflowId, queryClient]
35+
)
36+
37+
return { chatId: chatId ?? undefined, setChatId }
38+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { skipToken, useQuery } from '@tanstack/react-query'
2+
3+
export interface CopilotChatListItem {
4+
id: string
5+
title: string | null
6+
workflowId?: string
7+
updatedAt: string
8+
activeStreamId: string | null
9+
}
10+
11+
export const copilotChatsKeys = {
12+
all: ['copilot-chats'] as const,
13+
lists: () => [...copilotChatsKeys.all, 'list'] as const,
14+
list: (workflowId?: string) => [...copilotChatsKeys.lists(), workflowId ?? ''] as const,
15+
}
16+
17+
async function fetchCopilotChats(
18+
workflowId: string,
19+
signal?: AbortSignal
20+
): Promise<CopilotChatListItem[]> {
21+
const res = await fetch('/api/copilot/chats', { signal })
22+
if (!res.ok) return []
23+
const data = await res.json()
24+
const all = Array.isArray(data?.chats) ? (data.chats as CopilotChatListItem[]) : []
25+
return all.filter((c) => c.workflowId === workflowId)
26+
}
27+
28+
/**
29+
* Workflow-scoped copilot chat list. Each workflowId has its own cache entry
30+
* so switching workflows reads the right list synchronously instead of
31+
* showing the previous workflow's chats during the refetch.
32+
*/
33+
export function useCopilotChats(workflowId?: string) {
34+
return useQuery<CopilotChatListItem[]>({
35+
queryKey: copilotChatsKeys.list(workflowId),
36+
queryFn: workflowId ? ({ signal }) => fetchCopilotChats(workflowId, signal) : skipToken,
37+
staleTime: 30 * 1000,
38+
})
39+
}

apps/sim/lib/copilot/chat/lifecycle.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export async function getAccessibleCopilotChat(chatId: string, userId: string) {
2828
.limit(1)
2929

3030
if (!chat) {
31+
logger.warn('Copilot chat not found or not owned by user', { chatId, userId })
3132
return null
3233
}
3334

@@ -38,11 +39,21 @@ export async function getAccessibleCopilotChat(chatId: string, userId: string) {
3839
action: 'read',
3940
})
4041
if (!authorization.allowed || !authorization.workflow) {
42+
logger.warn('Copilot chat workflow not authorized for user', {
43+
chatId,
44+
userId,
45+
workflowId: chat.workflowId,
46+
})
4147
return null
4248
}
4349
} else if (chat.workspaceId) {
4450
const access = await checkWorkspaceAccess(chat.workspaceId, userId)
4551
if (!access.exists || !access.hasAccess) {
52+
logger.warn('Copilot chat workspace not accessible to user', {
53+
chatId,
54+
userId,
55+
workspaceId: chat.workspaceId,
56+
})
4657
return null
4758
}
4859
}
@@ -74,16 +85,33 @@ export async function resolveOrCreateChat(params: {
7485

7586
if (chat) {
7687
if (workflowId && chat.workflowId !== workflowId) {
88+
logger.warn('Copilot chat workflow mismatch', {
89+
chatId,
90+
userId,
91+
requestWorkflowId: workflowId,
92+
chatWorkflowId: chat.workflowId,
93+
})
7794
return { chatId, chat: null, conversationHistory: [], isNew: false }
7895
}
7996

8097
if (workspaceId && chat.workspaceId !== workspaceId) {
98+
logger.warn('Copilot chat workspace mismatch', {
99+
chatId,
100+
userId,
101+
requestWorkspaceId: workspaceId,
102+
chatWorkspaceId: chat.workspaceId,
103+
})
81104
return { chatId, chat: null, conversationHistory: [], isNew: false }
82105
}
83106

84107
if (chat.workflowId) {
85108
const activeWorkflow = await getActiveWorkflowRecord(chat.workflowId)
86109
if (!activeWorkflow) {
110+
logger.warn('Copilot chat workflow no longer active', {
111+
chatId,
112+
userId,
113+
workflowId: chat.workflowId,
114+
})
87115
return { chatId, chat: null, conversationHistory: [], isNew: false }
88116
}
89117
}

0 commit comments

Comments
 (0)