diff --git a/apps/sim/app/api/copilot/chats/route.ts b/apps/sim/app/api/copilot/chats/route.ts index 0aecdb462b9..7e58b525649 100644 --- a/apps/sim/app/api/copilot/chats/route.ts +++ b/apps/sim/app/api/copilot/chats/route.ts @@ -36,6 +36,8 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { workspaceId: copilotChats.workspaceId, activeStreamId: copilotChats.conversationId, updatedAt: copilotChats.updatedAt, + type: copilotChats.type, + resources: copilotChats.resources, }) .from(copilotChats) .leftJoin(workflow, eq(copilotChats.workflowId, workflow.id)) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index e93fe37cd6a..5891db3b0a3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -211,12 +211,19 @@ export const ResourceContent = memo(function ResourceContent({ interface ResourceActionsProps { workspaceId: string resource: MothershipResource + chatId?: string } -export function ResourceActions({ workspaceId, resource }: ResourceActionsProps) { +export function ResourceActions({ workspaceId, resource, chatId }: ResourceActionsProps) { switch (resource.type) { case 'workflow': - return + return ( + + ) case 'file': return case 'knowledgebase': @@ -244,9 +251,14 @@ export function ResourceActions({ workspaceId, resource }: ResourceActionsProps) interface EmbeddedWorkflowActionsProps { workspaceId: string workflowId: string + chatId?: string } -export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWorkflowActionsProps) { +export function EmbeddedWorkflowActions({ + workspaceId, + workflowId, + chatId, +}: EmbeddedWorkflowActionsProps) { const router = useRouter() const { navigateToSettings } = useSettingsNavigation() const { userPermissions: effectivePermissions } = useWorkspacePermissionsContext() @@ -284,7 +296,10 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor } const handleOpenWorkflow = () => { - window.open(`/workspace/${workspaceId}/w/${workflowId}`, '_blank') + const url = chatId + ? `/workspace/${workspaceId}/w/${workflowId}?chatId=${encodeURIComponent(chatId)}` + : `/workspace/${workspaceId}/w/${workflowId}` + router.push(url) } return ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx index be06ee8481a..f59216f1b24 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx @@ -117,7 +117,9 @@ export const MothershipView = memo( onReorderResources={onReorderResources} onCollapse={onCollapse} actions={ - active ? : null + active ? ( + + ) : null } previewMode={isActivePreviewable ? previewMode : undefined} onCyclePreviewMode={isActivePreviewable ? handleCyclePreview : undefined} 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 78ad3020518..f2c33298ea1 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 @@ -5,7 +5,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' import { History, Plus } from 'lucide-react' -import { useParams, useRouter } from 'next/navigation' +import { useParams, useRouter, useSearchParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { useShallow } from 'zustand/react/shallow' import { @@ -46,7 +46,11 @@ import { captureEvent } from '@/lib/posthog/client' import { generateWorkflowJson } from '@/lib/workflows/operations/import-export' import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components' -import { getWorkflowCopilotUseChatOptions, useChat } from '@/app/workspace/[workspaceId]/home/hooks' +import { + getMothershipUseChatOptions, + getWorkflowCopilotUseChatOptions, + useChat, +} from '@/app/workspace/[workspaceId]/home/hooks' import type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -118,7 +122,9 @@ interface PanelProps { export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: PanelProps = {}) { const router = useRouter() const params = useParams() + const searchParams = useSearchParams() const workspaceId = propWorkspaceId ?? (params.workspaceId as string) + const urlChatIdParam = searchParams?.get('chatId') ?? null const posthog = usePostHog() const posthogRef = useRef(posthog) @@ -250,11 +256,23 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel ) const [isCopilotHistoryOpen, setIsCopilotHistoryOpen] = useState(false) - const copilotChatTitle = useMemo( - () => - copilotChatId ? (copilotChatList.find((c) => c.id === copilotChatId)?.title ?? null) : null, + const selectedCopilotChat = useMemo( + () => (copilotChatId ? (copilotChatList.find((c) => c.id === copilotChatId) ?? null) : null), [copilotChatId, copilotChatList] ) + const copilotChatTitle = selectedCopilotChat?.title ?? null + + /** + * A selected chat is "foreign" to this workflow when it was started in + * Mothership (`type === 'mothership'`) but ended up referencing this + * workflow via `resources`. We keep the conversation continuable by + * routing sends through the Mothership branch — i.e. the request goes + * out without `workflowId`, so the server uses the broader Mothership + * agent surface that originally produced the chat. The trade-off is + * that resources spawned during continuation only show up in the + * Mothership view; this panel shows the conversation only. + */ + const isMothershipChat = selectedCopilotChat?.type === 'mothership' const queryClient = useQueryClient() const loadCopilotChats = useCallback(() => { @@ -264,8 +282,14 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel // 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). + // chat was deleted in another tab). When a `?chatId=` param is present in + // the URL (e.g. after clicking "Open Workflow" from a Mothership task), + // prefer that chat over the most recent so the original conversation is + // shown right away. The URL param is honored once per distinct value so + // returning to a workflow with a fresh `?chatId=` re-applies it instead of + // being shadowed by the once-per-workflow auto-select guard. const autoSelectAttemptedForRef = useRef>(new Set()) + const consumedUrlChatIdRef = useRef(null) useEffect(() => { if (!activeWorkflowId) return @@ -274,12 +298,23 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel return } + if ( + urlChatIdParam && + consumedUrlChatIdRef.current !== urlChatIdParam && + copilotChatList.find((c) => c.id === urlChatIdParam) + ) { + consumedUrlChatIdRef.current = urlChatIdParam + autoSelectAttemptedForRef.current.add(activeWorkflowId) + setCopilotChatId(urlChatIdParam) + return + } + if (copilotChatId) return if (autoSelectAttemptedForRef.current.has(activeWorkflowId)) return if (copilotChatList.length === 0) return autoSelectAttemptedForRef.current.add(activeWorkflowId) setCopilotChatId(copilotChatList[0].id) - }, [copilotChatList, copilotChatId, activeWorkflowId, setCopilotChatId]) + }, [copilotChatList, copilotChatId, activeWorkflowId, setCopilotChatId, urlChatIdParam]) useEffect(() => { posthogRef.current = posthog @@ -335,6 +370,40 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel [activeWorkflowId] ) + const handleCopilotRequestStarted = useCallback( + ({ requestId, userMessageId }: { requestId: string; userMessageId: string }) => { + captureEvent(posthogRef.current, 'task_request_started', { + workspace_id: workspaceId, + view: 'copilot', + request_id: requestId, + user_message_id: userMessageId, + }) + }, + [workspaceId] + ) + + const copilotChatOptions = useMemo( + () => + isMothershipChat + ? getMothershipUseChatOptions({ + onStreamEnd: loadCopilotChats, + onRequestStarted: handleCopilotRequestStarted, + }) + : getWorkflowCopilotUseChatOptions({ + workflowId: activeWorkflowId || undefined, + onTitleUpdate: loadCopilotChats, + onToolResult: handleCopilotToolResult, + onRequestStarted: handleCopilotRequestStarted, + }), + [ + isMothershipChat, + activeWorkflowId, + loadCopilotChats, + handleCopilotToolResult, + handleCopilotRequestStarted, + ] + ) + const { messages: copilotMessages, isSending: copilotIsSending, @@ -347,23 +416,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel sendNow: copilotSendNow, editQueuedMessage: copilotEditQueuedMessage, getCurrentRequestId: getCopilotCurrentRequestId, - } = useChat( - workspaceId, - copilotChatId, - getWorkflowCopilotUseChatOptions({ - workflowId: activeWorkflowId || undefined, - onTitleUpdate: loadCopilotChats, - onToolResult: handleCopilotToolResult, - onRequestStarted: ({ requestId, userMessageId }) => { - captureEvent(posthogRef.current, 'task_request_started', { - workspace_id: workspaceId, - view: 'copilot', - request_id: requestId, - user_message_id: userMessageId, - }) - }, - }) - ) + } = useChat(workspaceId, copilotChatId, copilotChatOptions) const handleCopilotNewChat = useCallback(() => { if (!activeWorkflowId || !workspaceId) return @@ -444,6 +497,20 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel setHasHydrated(true) }, [setHasHydrated]) + /** + * If the workflow page was opened with `?chatId=`, surface the copilot + * tab so the linked conversation is visible without an extra click. + * Re-applies whenever the param changes so returning to a workflow with + * a fresh `?chatId=` switches the tab again. + */ + const handledTabSwitchForChatIdRef = useRef(null) + useEffect(() => { + if (!urlChatIdParam) return + if (handledTabSwitchForChatIdRef.current === urlChatIdParam) return + handledTabSwitchForChatIdRef.current = urlChatIdParam + setActiveTab('copilot') + }, [urlChatIdParam, setActiveTab]) + useEffect(() => { const handler = (e: Event) => { const message = (e as CustomEvent<{ message: string }>).detail?.message diff --git a/apps/sim/hooks/queries/copilot-chats.ts b/apps/sim/hooks/queries/copilot-chats.ts index fe6f7462d2b..af54e6a203b 100644 --- a/apps/sim/hooks/queries/copilot-chats.ts +++ b/apps/sim/hooks/queries/copilot-chats.ts @@ -17,7 +17,11 @@ async function fetchCopilotChats( ): Promise { try { const data = await requestJson(listCopilotChatsContract, { signal }) - return data.chats.filter((c) => c.workflowId === workflowId) + return data.chats.filter( + (c) => + c.workflowId === workflowId || + c.resources?.some((r) => r.type === 'workflow' && r.id === workflowId) + ) } catch (error) { if (error instanceof ApiClientError) return [] throw error diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index 365ae41b9f5..53789308d8f 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -112,6 +112,12 @@ const copilotResourceTypeSchema = z.enum([ 'log', ]) +const copilotChatResourceSchema = z.object({ + type: copilotResourceTypeSchema, + id: z.string(), + title: z.string(), +}) + export const addCopilotChatResourceBodySchema = z.object({ chatId: z.string(), resource: z.object({ @@ -301,6 +307,8 @@ export const copilotChatListItemSchema = z.object({ workspaceId: z.string().nullable().optional(), activeStreamId: z.string().nullable(), updatedAt: z.string().nullable(), + type: z.enum(['mothership', 'copilot']).nullable().optional(), + resources: z.array(copilotChatResourceSchema).nullable().optional(), }) export type CopilotChatListItem = z.output @@ -378,12 +386,6 @@ const copilotCheckpointSchema = z.object({ updatedAt: z.string().nullable(), }) -const copilotChatResourceSchema = z.object({ - type: copilotResourceTypeSchema, - id: z.string(), - title: z.string(), -}) - const copilotAvailableModelSchema = z.object({ id: z.string(), friendlyName: z.string(),