Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'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 { History, Plus, Square } from 'lucide-react'
Expand Down Expand Up @@ -59,6 +59,7 @@ 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 { useDuplicateWorkflowMutation, useWorkflowMap } from '@/hooks/queries/workflows'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePermissionConfig } from '@/hooks/use-permission-config'
Expand Down Expand Up @@ -222,22 +223,44 @@ 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)
// Per-workflow chat memory lives in the React Query cache, keyed by
// workflowId. Switching workflows reads the right cache entry on its own,
// so no save/restore effect is needed. Refresh wipes the cache and the
// panel falls back to auto-selecting most recent.
const { chatId: copilotChatId, setChatId: setCopilotChatId } = useCopilotChatSelection(
activeWorkflowId ?? undefined
)

const [copilotChatList, setCopilotChatList] = useState<
{ id: string; title: string | null; updatedAt: string; activeStreamId: string | null }[]
>([])
const [isCopilotHistoryOpen, setIsCopilotHistoryOpen] = useState(false)

const copilotChatTitle = useMemo(
() =>
copilotChatId ? (copilotChatList.find((c) => c.id === copilotChatId)?.title ?? null) : null,
[copilotChatId, copilotChatList]
)

const copilotChatIdRef = useRef(copilotChatId)
copilotChatIdRef.current = copilotChatId
const copilotInitialLoadDoneRef = useRef(false)
// Tracks the live workflow so async chat-list fetches can detect
// workflow switches that happened mid-flight and bail out.
const activeWorkflowIdRef = useRef(activeWorkflowId)
activeWorkflowIdRef.current = activeWorkflowId

const loadCopilotChats = useCallback(() => {
if (!activeWorkflowId) return
const requestWorkflowId = activeWorkflowId
fetch('/api/copilot/chats')
.then((res) => (res.ok ? res.json() : { chats: [] }))
.then((data) => {
// Stale-fetch guard: bail if the user switched workflows mid-flight.
// Without this the in-flight response would clobber the new
// workflow's state (filtering against the old workflow id, clearing
// the restored chat, and auto-selecting the wrong list's first chat).
if (requestWorkflowId !== activeWorkflowIdRef.current) return
const allChats = Array.isArray(data?.chats) ? data.chats : []
const filtered = allChats.filter(
(c: { workflowId?: string }) => c.workflowId === activeWorkflowId
Expand All @@ -250,20 +273,22 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
setCopilotChatList(filtered)

const currentId = copilotChatIdRef.current
if (currentId) {
const match = filtered.find((c: { id: string }) => c.id === currentId)
if (match?.title) setCopilotChatTitle(match.title)
let resolvedCurrentId = currentId
if (currentId && !filtered.find((c: { id: string }) => c.id === currentId)) {
// Remembered chat was deleted (here or in another tab). Drop it
// so the next send doesn't hit a 404; setCopilotChatId(undefined)
// also clears the workflow's cached selection.
setCopilotChatId(undefined)
resolvedCurrentId = undefined
}

if (!copilotInitialLoadDoneRef.current && !currentId && filtered.length > 0) {
copilotInitialLoadDoneRef.current = true
if (!copilotInitialLoadDoneRef.current && !resolvedCurrentId && filtered.length > 0) {
setCopilotChatId(filtered[0].id)
setCopilotChatTitle(filtered[0].title)
}
copilotInitialLoadDoneRef.current = true
Comment thread
TheodoreSpeaks marked this conversation as resolved.
Outdated
})
.catch(() => {})
}, [activeWorkflowId])
}, [activeWorkflowId, setCopilotChatId])

useEffect(() => {
copilotInitialLoadDoneRef.current = false
Expand All @@ -274,11 +299,13 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
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) => {
Expand All @@ -290,13 +317,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(
Expand Down Expand Up @@ -361,14 +387,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(() => {
Expand All @@ -383,7 +408,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(() => {
Expand Down
44 changes: 44 additions & 0 deletions apps/sim/hooks/queries/copilot-chat-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useCallback } from 'react'
import { 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. The active workflow's
* chatId lives in the React Query cache under a workflow-keyed entry, so
* switching workflows naturally reads the per-workflow remembered value
* with no save/restore effect required. No `queryFn` runs — values land
* exclusively via the returned setter.
*
* In-memory only (no `persistQueryClient`); refresh wipes the memory and
* the panel falls back to auto-selecting the workflow's most recent chat.
*/
export function useCopilotChatSelection(workflowId?: string) {
const queryClient = useQueryClient()

const { data: chatId } = useQuery({
queryKey: copilotChatSelectionKeys.workflow(workflowId),
queryFn: (): string | null => null,
enabled: false,
staleTime: Number.POSITIVE_INFINITY,
initialData: null,
})
Comment thread
TheodoreSpeaks marked this conversation as resolved.
Outdated

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 }
}
28 changes: 28 additions & 0 deletions apps/sim/lib/copilot/chat/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
}
}
Expand Down Expand Up @@ -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 }
}
}
Expand Down
Loading