From a9d42226872a26461fee15fc3f64c125eea31066 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 28 Apr 2026 13:24:57 -0700 Subject: [PATCH 1/4] fix(copilot): use different chats for different workflows --- .../w/[workflowId]/components/panel/panel.tsx | 59 +++++++++++++++++-- .../hooks/queries/copilot-chat-selection.ts | 37 ++++++++++++ apps/sim/lib/copilot/chat/lifecycle.ts | 28 +++++++++ 3 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 apps/sim/hooks/queries/copilot-chat-selection.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 58995808c79..7f88f5b64e8 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 @@ -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' @@ -232,12 +233,48 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel 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 + + // Per-workflow chat memory: switching A→B→A returns to A's last-used chat + // instead of jumping to A's most recent. Backed by the React Query cache so + // it survives in-session workflow switches and clears on hard refresh. + const { getChatId: getRememberedChatId, setChatId: setRememberedChatId } = + useCopilotChatSelection() + const lastWorkflowIdRef = useRef(null) + + useEffect(() => { + const previous = lastWorkflowIdRef.current + lastWorkflowIdRef.current = activeWorkflowId ?? null + if (previous === activeWorkflowId) return + + if (previous && copilotChatIdRef.current) { + setRememberedChatId(previous, copilotChatIdRef.current) + } + + if (activeWorkflowId) { + const remembered = getRememberedChatId(activeWorkflowId) + setCopilotChatId(remembered) + setCopilotChatTitle(null) + } else { + setCopilotChatId(undefined) + setCopilotChatTitle(null) + } + }, [activeWorkflowId, getRememberedChatId, setRememberedChatId]) 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 @@ -250,20 +287,29 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel setCopilotChatList(filtered) const currentId = copilotChatIdRef.current + let resolvedCurrentId = currentId if (currentId) { const match = filtered.find((c: { id: string }) => c.id === currentId) - if (match?.title) setCopilotChatTitle(match.title) + if (match) { + if (match.title) setCopilotChatTitle(match.title) + } else { + // Remembered chat was deleted (here or in another tab). Drop it + // so the next send doesn't hit a 404, and forget it for next time. + setRememberedChatId(activeWorkflowId, undefined) + setCopilotChatId(undefined) + setCopilotChatTitle(null) + 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 }) .catch(() => {}) - }, [activeWorkflowId]) + }, [activeWorkflowId, setRememberedChatId]) useEffect(() => { copilotInitialLoadDoneRef.current = false @@ -291,12 +337,15 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel if (copilotChatId === chatId) { setCopilotChatId(undefined) setCopilotChatTitle(null) + if (activeWorkflowId) { + setRememberedChatId(activeWorkflowId, undefined) + } } loadCopilotChats() }) .catch(() => {}) }, - [copilotChatId, loadCopilotChats] + [copilotChatId, loadCopilotChats, activeWorkflowId, setRememberedChatId] ) const handleCopilotToolResult = useCallback( diff --git a/apps/sim/hooks/queries/copilot-chat-selection.ts b/apps/sim/hooks/queries/copilot-chat-selection.ts new file mode 100644 index 00000000000..87f087504bc --- /dev/null +++ b/apps/sim/hooks/queries/copilot-chat-selection.ts @@ -0,0 +1,37 @@ +import { useCallback } from 'react' +import { 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, +} + +/** + * In-memory selection of which copilot chat is active per workflow. + * Backed by the React Query cache as a keyed KV store — no `queryFn`, + * values only land via `setChatId`. Survives in-session workflow switches + * so A → B → A returns to A's last-used chat; cleared on hard refresh. + */ +export function useCopilotChatSelection() { + const queryClient = useQueryClient() + + const getChatId = useCallback( + (workflowId: string): string | undefined => + queryClient.getQueryData(copilotChatSelectionKeys.workflow(workflowId)), + [queryClient] + ) + + const setChatId = useCallback( + (workflowId: string, chatId: string | undefined) => { + queryClient.setQueryData( + copilotChatSelectionKeys.workflow(workflowId), + chatId + ) + }, + [queryClient] + ) + + return { getChatId, setChatId } +} diff --git a/apps/sim/lib/copilot/chat/lifecycle.ts b/apps/sim/lib/copilot/chat/lifecycle.ts index 9dd3e5d7c05..c74a90317b6 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.ts @@ -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 } } } From 77c3cf8cfda628ff4e29ae2228d23ca23b0bbfd5 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 28 Apr 2026 13:39:19 -0700 Subject: [PATCH 2/4] remove use effect --- .../w/[workflowId]/components/panel/panel.tsx | 88 +++++++------------ .../hooks/queries/copilot-chat-selection.ts | 39 ++++---- 2 files changed, 55 insertions(+), 72 deletions(-) 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 7f88f5b64e8..3fcdcae787d 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 @@ -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' @@ -223,13 +223,25 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null const { isSnapshotView } = useCurrentWorkflow() - const [copilotChatId, setCopilotChatId] = useState(undefined) - const [copilotChatTitle, setCopilotChatTitle] = useState(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) @@ -238,32 +250,6 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const activeWorkflowIdRef = useRef(activeWorkflowId) activeWorkflowIdRef.current = activeWorkflowId - // Per-workflow chat memory: switching A→B→A returns to A's last-used chat - // instead of jumping to A's most recent. Backed by the React Query cache so - // it survives in-session workflow switches and clears on hard refresh. - const { getChatId: getRememberedChatId, setChatId: setRememberedChatId } = - useCopilotChatSelection() - const lastWorkflowIdRef = useRef(null) - - useEffect(() => { - const previous = lastWorkflowIdRef.current - lastWorkflowIdRef.current = activeWorkflowId ?? null - if (previous === activeWorkflowId) return - - if (previous && copilotChatIdRef.current) { - setRememberedChatId(previous, copilotChatIdRef.current) - } - - if (activeWorkflowId) { - const remembered = getRememberedChatId(activeWorkflowId) - setCopilotChatId(remembered) - setCopilotChatTitle(null) - } else { - setCopilotChatId(undefined) - setCopilotChatTitle(null) - } - }, [activeWorkflowId, getRememberedChatId, setRememberedChatId]) - const loadCopilotChats = useCallback(() => { if (!activeWorkflowId) return const requestWorkflowId = activeWorkflowId @@ -288,28 +274,21 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const currentId = copilotChatIdRef.current let resolvedCurrentId = currentId - if (currentId) { - const match = filtered.find((c: { id: string }) => c.id === currentId) - if (match) { - if (match.title) setCopilotChatTitle(match.title) - } else { - // Remembered chat was deleted (here or in another tab). Drop it - // so the next send doesn't hit a 404, and forget it for next time. - setRememberedChatId(activeWorkflowId, undefined) - setCopilotChatId(undefined) - setCopilotChatTitle(null) - resolvedCurrentId = undefined - } + 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 && !resolvedCurrentId && filtered.length > 0) { setCopilotChatId(filtered[0].id) - setCopilotChatTitle(filtered[0].title) } copilotInitialLoadDoneRef.current = true }) .catch(() => {}) - }, [activeWorkflowId, setRememberedChatId]) + }, [activeWorkflowId, setCopilotChatId]) useEffect(() => { copilotInitialLoadDoneRef.current = false @@ -320,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) => { @@ -336,16 +317,12 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel .then(() => { if (copilotChatId === chatId) { setCopilotChatId(undefined) - setCopilotChatTitle(null) - if (activeWorkflowId) { - setRememberedChatId(activeWorkflowId, undefined) - } } loadCopilotChats() }) .catch(() => {}) }, - [copilotChatId, loadCopilotChats, activeWorkflowId, setRememberedChatId] + [copilotChatId, loadCopilotChats, setCopilotChatId] ) const handleCopilotToolResult = useCallback( @@ -410,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(undefined) useEffect(() => { @@ -432,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(() => { diff --git a/apps/sim/hooks/queries/copilot-chat-selection.ts b/apps/sim/hooks/queries/copilot-chat-selection.ts index 87f087504bc..4094827eb0d 100644 --- a/apps/sim/hooks/queries/copilot-chat-selection.ts +++ b/apps/sim/hooks/queries/copilot-chat-selection.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react' -import { useQueryClient } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' export const copilotChatSelectionKeys = { all: ['copilot-chat-selection'] as const, @@ -9,29 +9,36 @@ export const copilotChatSelectionKeys = { } /** - * In-memory selection of which copilot chat is active per workflow. - * Backed by the React Query cache as a keyed KV store — no `queryFn`, - * values only land via `setChatId`. Survives in-session workflow switches - * so A → B → A returns to A's last-used chat; cleared on hard refresh. + * 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() { +export function useCopilotChatSelection(workflowId?: string) { const queryClient = useQueryClient() - const getChatId = useCallback( - (workflowId: string): string | undefined => - queryClient.getQueryData(copilotChatSelectionKeys.workflow(workflowId)), - [queryClient] - ) + const { data: chatId } = useQuery({ + queryKey: copilotChatSelectionKeys.workflow(workflowId), + queryFn: (): string | null => null, + enabled: false, + staleTime: Number.POSITIVE_INFINITY, + initialData: null, + }) const setChatId = useCallback( - (workflowId: string, chatId: string | undefined) => { - queryClient.setQueryData( + (next: string | undefined) => { + if (!workflowId) return + queryClient.setQueryData( copilotChatSelectionKeys.workflow(workflowId), - chatId + next ?? null ) }, - [queryClient] + [workflowId, queryClient] ) - return { getChatId, setChatId } + return { chatId: chatId ?? undefined, setChatId } } From 2a64808f67d90e126de36478aa0768b3b6344fe1 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 28 Apr 2026 13:48:29 -0700 Subject: [PATCH 3/4] trim comments --- .../w/[workflowId]/components/panel/panel.tsx | 14 +------------- apps/sim/hooks/queries/copilot-chat-selection.ts | 10 ++-------- 2 files changed, 3 insertions(+), 21 deletions(-) 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 3fcdcae787d..513708c7ce8 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 @@ -223,10 +223,6 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null const { isSnapshotView } = useCurrentWorkflow() - // 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 ) @@ -245,8 +241,6 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel 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 @@ -256,10 +250,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel 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). + // Drop responses for a workflow we've already switched away from. if (requestWorkflowId !== activeWorkflowIdRef.current) return const allChats = Array.isArray(data?.chats) ? data.chats : [] const filtered = allChats.filter( @@ -275,9 +266,6 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const currentId = copilotChatIdRef.current 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 } diff --git a/apps/sim/hooks/queries/copilot-chat-selection.ts b/apps/sim/hooks/queries/copilot-chat-selection.ts index 4094827eb0d..7d4d744f34f 100644 --- a/apps/sim/hooks/queries/copilot-chat-selection.ts +++ b/apps/sim/hooks/queries/copilot-chat-selection.ts @@ -9,14 +9,8 @@ export const copilotChatSelectionKeys = { } /** - * 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. + * Reactive per-workflow copilot chat selection. Values are written via the + * returned setter; the queryFn is never invoked. */ export function useCopilotChatSelection(workflowId?: string) { const queryClient = useQueryClient() From 4175e322c94bc2cce93362270cdffdad27085b54 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 28 Apr 2026 15:50:49 -0700 Subject: [PATCH 4/4] address greptile comments --- .../w/[workflowId]/components/panel/panel.tsx | 75 ++++++++----------- .../hooks/queries/copilot-chat-selection.ts | 10 +-- apps/sim/hooks/queries/copilot-chats.ts | 39 ++++++++++ 3 files changed, 76 insertions(+), 48 deletions(-) create mode 100644 apps/sim/hooks/queries/copilot-chats.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 513708c7ce8..55369c378ca 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 @@ -3,6 +3,7 @@ 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' @@ -60,6 +61,11 @@ import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowI 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' @@ -78,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. * @@ -227,9 +234,9 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel activeWorkflowId ?? undefined ) - const [copilotChatList, setCopilotChatList] = useState< - { id: string; title: string | null; updatedAt: string; activeStreamId: string | null }[] - >([]) + const { data: copilotChatList = EMPTY_COPILOT_CHATS } = useCopilotChats( + activeWorkflowId ?? undefined + ) const [isCopilotHistoryOpen, setIsCopilotHistoryOpen] = useState(false) const copilotChatTitle = useMemo( @@ -238,50 +245,32 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel [copilotChatId, copilotChatList] ) - const copilotChatIdRef = useRef(copilotChatId) - copilotChatIdRef.current = copilotChatId - const copilotInitialLoadDoneRef = useRef(false) - const activeWorkflowIdRef = useRef(activeWorkflowId) - activeWorkflowIdRef.current = activeWorkflowId - + const queryClient = useQueryClient() const loadCopilotChats = useCallback(() => { if (!activeWorkflowId) return - const requestWorkflowId = activeWorkflowId - fetch('/api/copilot/chats') - .then((res) => (res.ok ? res.json() : { chats: [] })) - .then((data) => { - // Drop responses for a workflow we've already switched away from. - if (requestWorkflowId !== activeWorkflowIdRef.current) return - 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 - let resolvedCurrentId = currentId - if (currentId && !filtered.find((c: { id: string }) => c.id === currentId)) { - setCopilotChatId(undefined) - resolvedCurrentId = undefined - } - - if (!copilotInitialLoadDoneRef.current && !resolvedCurrentId && filtered.length > 0) { - setCopilotChatId(filtered[0].id) - } - copilotInitialLoadDoneRef.current = true - }) - .catch(() => {}) - }, [activeWorkflowId, setCopilotChatId]) + 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>(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 diff --git a/apps/sim/hooks/queries/copilot-chat-selection.ts b/apps/sim/hooks/queries/copilot-chat-selection.ts index 7d4d744f34f..db58cde327c 100644 --- a/apps/sim/hooks/queries/copilot-chat-selection.ts +++ b/apps/sim/hooks/queries/copilot-chat-selection.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react' -import { useQuery, useQueryClient } from '@tanstack/react-query' +import { skipToken, useQuery, useQueryClient } from '@tanstack/react-query' export const copilotChatSelectionKeys = { all: ['copilot-chat-selection'] as const, @@ -10,15 +10,15 @@ export const copilotChatSelectionKeys = { /** * Reactive per-workflow copilot chat selection. Values are written via the - * returned setter; the queryFn is never invoked. + * 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({ + const { data: chatId } = useQuery({ queryKey: copilotChatSelectionKeys.workflow(workflowId), - queryFn: (): string | null => null, - enabled: false, + queryFn: skipToken, staleTime: Number.POSITIVE_INFINITY, initialData: null, }) diff --git a/apps/sim/hooks/queries/copilot-chats.ts b/apps/sim/hooks/queries/copilot-chats.ts new file mode 100644 index 00000000000..e37ccd71f8d --- /dev/null +++ b/apps/sim/hooks/queries/copilot-chats.ts @@ -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 { + 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({ + queryKey: copilotChatsKeys.list(workflowId), + queryFn: workflowId ? ({ signal }) => fetchCopilotChats(workflowId, signal) : skipToken, + staleTime: 30 * 1000, + }) +}