fix(copilot): use different chats for different workflows (#4324)

* fix(copilot): use different chats for different workflows

* remove use effect

* trim comments
This commit is contained in:
Theodore Li
2026-04-28 19:19:01 -04:00
committed by GitHub
parent feeb0a8920
commit 18a559bfb5
4 changed files with 158 additions and 51 deletions
@@ -1,8 +1,9 @@
'use client'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { useQueryClient } from '@tanstack/react-query'
import { History, Plus, Square } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
@@ -59,6 +60,12 @@ import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
import { useCopilotChatSelection } from '@/hooks/queries/copilot-chat-selection'
import {
type CopilotChatListItem,
copilotChatsKeys,
useCopilotChats,
} from '@/hooks/queries/copilot-chats'
import { useDuplicateWorkflowMutation, useWorkflowMap } from '@/hooks/queries/workflows'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -77,6 +84,7 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('Panel')
const EMPTY_COPILOT_CHATS: readonly CopilotChatListItem[] = []
/**
* Panel component with resizable width and tab navigation that persists across page refreshes.
*
@@ -222,63 +230,59 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null
const { isSnapshotView } = useCurrentWorkflow()
const [copilotChatId, setCopilotChatId] = useState<string | undefined>(undefined)
const [copilotChatTitle, setCopilotChatTitle] = useState<string | null>(null)
const [copilotChatList, setCopilotChatList] = useState<
{ id: string; title: string | null; updatedAt: string; activeStreamId: string | null }[]
>([])
const { chatId: copilotChatId, setChatId: setCopilotChatId } = useCopilotChatSelection(
activeWorkflowId ?? undefined
)
const { data: copilotChatList = EMPTY_COPILOT_CHATS } = useCopilotChats(
activeWorkflowId ?? undefined
)
const [isCopilotHistoryOpen, setIsCopilotHistoryOpen] = useState(false)
const copilotChatIdRef = useRef(copilotChatId)
copilotChatIdRef.current = copilotChatId
const copilotInitialLoadDoneRef = useRef(false)
const copilotChatTitle = useMemo(
() =>
copilotChatId ? (copilotChatList.find((c) => c.id === copilotChatId)?.title ?? null) : null,
[copilotChatId, copilotChatList]
)
const queryClient = useQueryClient()
const loadCopilotChats = useCallback(() => {
if (!activeWorkflowId) return
fetch('/api/copilot/chats')
.then((res) => (res.ok ? res.json() : { chats: [] }))
.then((data) => {
const allChats = Array.isArray(data?.chats) ? data.chats : []
const filtered = allChats.filter(
(c: { workflowId?: string }) => c.workflowId === activeWorkflowId
) as Array<{
id: string
title: string | null
updatedAt: string
activeStreamId: string | null
}>
setCopilotChatList(filtered)
const currentId = copilotChatIdRef.current
if (currentId) {
const match = filtered.find((c: { id: string }) => c.id === currentId)
if (match?.title) setCopilotChatTitle(match.title)
}
if (!copilotInitialLoadDoneRef.current && !currentId && filtered.length > 0) {
copilotInitialLoadDoneRef.current = true
setCopilotChatId(filtered[0].id)
setCopilotChatTitle(filtered[0].title)
}
copilotInitialLoadDoneRef.current = true
})
.catch(() => {})
}, [activeWorkflowId])
queryClient.invalidateQueries({ queryKey: copilotChatsKeys.list(activeWorkflowId) })
}, [activeWorkflowId, queryClient])
// Auto-select most recent on first list arrival per workflow, and drop a
// selection that no longer matches anything in the current list (e.g. the
// chat was deleted in another tab).
const autoSelectAttemptedForRef = useRef<Set<string>>(new Set())
useEffect(() => {
copilotInitialLoadDoneRef.current = false
loadCopilotChats()
}, [loadCopilotChats])
if (!activeWorkflowId) return
if (copilotChatId && !copilotChatList.find((c) => c.id === copilotChatId)) {
setCopilotChatId(undefined)
return
}
if (copilotChatId) return
if (autoSelectAttemptedForRef.current.has(activeWorkflowId)) return
autoSelectAttemptedForRef.current.add(activeWorkflowId)
if (copilotChatList.length > 0) {
setCopilotChatId(copilotChatList[0].id)
}
}, [copilotChatList, copilotChatId, activeWorkflowId, setCopilotChatId])
useEffect(() => {
posthogRef.current = posthog
}, [posthog])
const handleCopilotSelectChat = useCallback((chat: { id: string; title: string | null }) => {
setCopilotChatId(chat.id)
setCopilotChatTitle(chat.title)
setIsCopilotHistoryOpen(false)
}, [])
const handleCopilotSelectChat = useCallback(
(chat: { id: string; title: string | null }) => {
setCopilotChatId(chat.id)
setIsCopilotHistoryOpen(false)
},
[setCopilotChatId]
)
const handleCopilotDeleteChat = useCallback(
(chatId: string) => {
@@ -290,13 +294,12 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
.then(() => {
if (copilotChatId === chatId) {
setCopilotChatId(undefined)
setCopilotChatTitle(null)
}
loadCopilotChats()
})
.catch(() => {})
},
[copilotChatId, loadCopilotChats]
[copilotChatId, loadCopilotChats, setCopilotChatId]
)
const handleCopilotToolResult = useCallback(
@@ -361,14 +364,13 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
.then((data: { id?: string }) => {
if (data?.id) {
setCopilotChatId(data.id)
setCopilotChatTitle(null)
loadCopilotChats()
}
})
.catch((err) => {
logger.error('Failed to create copilot chat', err)
})
}, [activeWorkflowId, workspaceId, loadCopilotChats])
}, [activeWorkflowId, workspaceId, loadCopilotChats, setCopilotChatId])
const prevResolvedRef = useRef<string | undefined>(undefined)
useEffect(() => {
@@ -383,7 +385,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
} else {
prevResolvedRef.current = copilotResolvedChatId
}
}, [copilotResolvedChatId, copilotChatId, loadCopilotChats])
}, [copilotResolvedChatId, copilotChatId, loadCopilotChats, setCopilotChatId])
const wasCopilotSendingRef = useRef(false)
useEffect(() => {
@@ -0,0 +1,38 @@
import { useCallback } from 'react'
import { skipToken, useQuery, useQueryClient } from '@tanstack/react-query'
export const copilotChatSelectionKeys = {
all: ['copilot-chat-selection'] as const,
workflows: () => [...copilotChatSelectionKeys.all, 'workflow'] as const,
workflow: (workflowId?: string) =>
[...copilotChatSelectionKeys.workflows(), workflowId ?? ''] as const,
}
/**
* Reactive per-workflow copilot chat selection. Values are written via the
* returned setter; queryFn is `skipToken` so the cache only ever holds
* what setQueryData puts there.
*/
export function useCopilotChatSelection(workflowId?: string) {
const queryClient = useQueryClient()
const { data: chatId } = useQuery<string | null>({
queryKey: copilotChatSelectionKeys.workflow(workflowId),
queryFn: skipToken,
staleTime: Number.POSITIVE_INFINITY,
initialData: null,
})
const setChatId = useCallback(
(next: string | undefined) => {
if (!workflowId) return
queryClient.setQueryData<string | null>(
copilotChatSelectionKeys.workflow(workflowId),
next ?? null
)
},
[workflowId, queryClient]
)
return { chatId: chatId ?? undefined, setChatId }
}
+39
View File
@@ -0,0 +1,39 @@
import { skipToken, useQuery } from '@tanstack/react-query'
export interface CopilotChatListItem {
id: string
title: string | null
workflowId?: string
updatedAt: string
activeStreamId: string | null
}
export const copilotChatsKeys = {
all: ['copilot-chats'] as const,
lists: () => [...copilotChatsKeys.all, 'list'] as const,
list: (workflowId?: string) => [...copilotChatsKeys.lists(), workflowId ?? ''] as const,
}
async function fetchCopilotChats(
workflowId: string,
signal?: AbortSignal
): Promise<CopilotChatListItem[]> {
const res = await fetch('/api/copilot/chats', { signal })
if (!res.ok) return []
const data = await res.json()
const all = Array.isArray(data?.chats) ? (data.chats as CopilotChatListItem[]) : []
return all.filter((c) => c.workflowId === workflowId)
}
/**
* Workflow-scoped copilot chat list. Each workflowId has its own cache entry
* so switching workflows reads the right list synchronously instead of
* showing the previous workflow's chats during the refetch.
*/
export function useCopilotChats(workflowId?: string) {
return useQuery<CopilotChatListItem[]>({
queryKey: copilotChatsKeys.list(workflowId),
queryFn: workflowId ? ({ signal }) => fetchCopilotChats(workflowId, signal) : skipToken,
staleTime: 30 * 1000,
})
}
+28
View File
@@ -28,6 +28,7 @@ export async function getAccessibleCopilotChat(chatId: string, userId: string) {
.limit(1)
if (!chat) {
logger.warn('Copilot chat not found or not owned by user', { chatId, userId })
return null
}
@@ -38,11 +39,21 @@ export async function getAccessibleCopilotChat(chatId: string, userId: string) {
action: 'read',
})
if (!authorization.allowed || !authorization.workflow) {
logger.warn('Copilot chat workflow not authorized for user', {
chatId,
userId,
workflowId: chat.workflowId,
})
return null
}
} else if (chat.workspaceId) {
const access = await checkWorkspaceAccess(chat.workspaceId, userId)
if (!access.exists || !access.hasAccess) {
logger.warn('Copilot chat workspace not accessible to user', {
chatId,
userId,
workspaceId: chat.workspaceId,
})
return null
}
}
@@ -74,16 +85,33 @@ export async function resolveOrCreateChat(params: {
if (chat) {
if (workflowId && chat.workflowId !== workflowId) {
logger.warn('Copilot chat workflow mismatch', {
chatId,
userId,
requestWorkflowId: workflowId,
chatWorkflowId: chat.workflowId,
})
return { chatId, chat: null, conversationHistory: [], isNew: false }
}
if (workspaceId && chat.workspaceId !== workspaceId) {
logger.warn('Copilot chat workspace mismatch', {
chatId,
userId,
requestWorkspaceId: workspaceId,
chatWorkspaceId: chat.workspaceId,
})
return { chatId, chat: null, conversationHistory: [], isNew: false }
}
if (chat.workflowId) {
const activeWorkflow = await getActiveWorkflowRecord(chat.workflowId)
if (!activeWorkflow) {
logger.warn('Copilot chat workflow no longer active', {
chatId,
userId,
workflowId: chat.workflowId,
})
return { chatId, chat: null, conversationHistory: [], isNew: false }
}
}