Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions apps/sim/app/api/copilot/chats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <EmbeddedWorkflowActions workspaceId={workspaceId} workflowId={resource.id} />
return (
<EmbeddedWorkflowActions
workspaceId={workspaceId}
workflowId={resource.id}
chatId={chatId}
/>
)
case 'file':
return <EmbeddedFileActions workspaceId={workspaceId} fileId={resource.id} />
case 'knowledgebase':
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open Workflow navigates same tab instead of new tab

High Severity

handleOpenWorkflow was changed from window.open(url, '_blank') (opens a new tab) to router.push(url) (same-tab navigation). This means clicking "Open Workflow" from a Mothership task now navigates away from the Mothership view, causing the user to lose their in-progress conversation context. The PR test plan explicitly expects "a new tab opens," confirming this is unintended. The original window.open call just needs the ?chatId= query string appended while keeping '_blank'.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bc431ff. Configure here.

}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ export const MothershipView = memo(
onReorderResources={onReorderResources}
onCollapse={onCollapse}
actions={
active ? <ResourceActions workspaceId={workspaceId} resource={active} /> : null
active ? (
<ResourceActions workspaceId={workspaceId} resource={active} chatId={chatId} />
) : null
}
previewMode={isActivePreviewable ? previewMode : undefined}
onCyclePreviewMode={isActivePreviewable ? handleCyclePreview : undefined}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mothership chat read-only mode never applied to UI

High Severity

isMothershipChat is computed but only used to switch copilotChatOptions between routing branches — it is never used to hide the input footer. MothershipChat has no readOnly prop, and none is passed at the call site in panel.tsx. As a result, users viewing a Mothership chat in the workflow copilot panel will see an active input footer and can submit messages, directly contradicting the PR description's stated behavior ("the input footer is hidden") and the test-plan step "Verify the input footer is hidden (read-only)."

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6be5374. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale flag on this thread. The "input footer is hidden / read-only" wording was from an earlier iteration — this commit (and the PR description) deliberately switched to continuation: the panel swaps useChat to the Mothership branch (getMothershipUseChatOptions, no workflowId in the request) so users can keep talking. isMothershipChat is what drives that swap. No readOnly is intended.


const queryClient = useQueryClient()
const loadCopilotChats = useCallback(() => {
Expand All @@ -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<Set<string>>(new Set())
const consumedUrlChatIdRef = useRef<string | null>(null)
useEffect(() => {
if (!activeWorkflowId) return

Expand All @@ -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])
Comment on lines 291 to +317
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 autoSelectAttemptedForRef prevents re-honoring ?chatId= after in-session workflow navigation

autoSelectAttemptedForRef is keyed by activeWorkflowId and never cleared. If the user opens the workflow with ?chatId=<mothership_id>, navigates to a different workflow (via the sidebar), then navigates back to the same workflow in the same session, the ref still contains the original activeWorkflowId so the urlChatIdParam is silently ignored on the return visit. The most-recently-used chat is auto-selected instead of the linked Mothership chat. This is a subtle divergence from the intended deep-link behaviour, most likely to appear in SPAs where the component stays mounted across workflow switches.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in bc431ff. Replaced the once-per-workflow Set guard for URL-driven selection with a consumedUrlChatIdRef keyed by the chatId value itself: any time urlChatIdParam changes to a value that is in the list, we honor it. Returning to the same workflow with a fresh ?chatId= now re-applies. The auto-select-most-recent guard still runs once per workflow when no URL param is present. Same fix applied to the copilot-tab activation effect.


useEffect(() => {
posthogRef.current = posthog
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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<string | null>(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
Expand Down
6 changes: 5 additions & 1 deletion apps/sim/hooks/queries/copilot-chats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ async function fetchCopilotChats(
): Promise<CopilotChatListItem[]> {
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
Expand Down
14 changes: 8 additions & 6 deletions apps/sim/lib/api/contracts/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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<typeof copilotChatListItemSchema>

Expand Down Expand Up @@ -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(),
Expand Down
Loading