Skip to content

Commit 528bcfb

Browse files
committed
feat(copilot): preserve mothership chat when opening workflow
Clicking "Open Workflow" from a Mothership task now deep-links the originating chat into the workflow page's copilot panel as read-only history, so users don't lose the conversation that produced the workflow.
1 parent 29affda commit 528bcfb

7 files changed

Lines changed: 110 additions & 41 deletions

File tree

apps/sim/app/api/copilot/chats/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export const GET = withRouteHandler(async (_request: NextRequest) => {
3636
workspaceId: copilotChats.workspaceId,
3737
activeStreamId: copilotChats.conversationId,
3838
updatedAt: copilotChats.updatedAt,
39+
resources: copilotChats.resources,
3940
})
4041
.from(copilotChats)
4142
.leftJoin(workflow, eq(copilotChats.workflowId, workflow.id))

apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ interface MothershipChatProps {
5252
animateInput?: boolean
5353
onInputAnimationEnd?: () => void
5454
className?: string
55+
/**
56+
* When true, hides the input footer so the conversation is shown as
57+
* read-only history. Used when a chat is surfaced outside the context
58+
* where it can be safely continued (e.g. a Mothership chat opened from
59+
* a workflow page).
60+
*/
61+
readOnly?: boolean
5562
}
5663

5764
const LAYOUT_STYLES = {
@@ -100,6 +107,7 @@ export function MothershipChat({
100107
animateInput = false,
101108
onInputAnimationEnd,
102109
className,
110+
readOnly = false,
103111
}: MothershipChatProps) {
104112
const styles = LAYOUT_STYLES[layout]
105113
const isStreamActive = isSending || isReconnecting
@@ -227,32 +235,34 @@ export function MothershipChat({
227235
)}
228236
</div>
229237

230-
<div
231-
className={cn(styles.footer, animateInput && 'animate-slide-in-bottom')}
232-
onAnimationEnd={animateInput ? onInputAnimationEnd : undefined}
233-
>
234-
<div className={styles.footerInner}>
235-
<QueuedMessages
236-
messageQueue={messageQueue}
237-
onRemove={onRemoveQueuedMessage}
238-
onSendNow={onSendQueuedMessage}
239-
onEdit={handleEditQueued}
240-
/>
241-
<UserInput
242-
ref={userInputRef}
243-
onSubmit={onSubmit}
244-
isSending={isStreamActive}
245-
onStopGeneration={onStopGeneration}
246-
isInitialView={false}
247-
userId={userId}
248-
onContextAdd={onContextAdd}
249-
onContextRemove={onContextRemove}
250-
onSendQueuedHead={handleSendQueuedHead}
251-
onEditQueuedTail={handleEditQueuedTail}
252-
draftScopeKey={draftScopeKey}
253-
/>
238+
{!readOnly && (
239+
<div
240+
className={cn(styles.footer, animateInput && 'animate-slide-in-bottom')}
241+
onAnimationEnd={animateInput ? onInputAnimationEnd : undefined}
242+
>
243+
<div className={styles.footerInner}>
244+
<QueuedMessages
245+
messageQueue={messageQueue}
246+
onRemove={onRemoveQueuedMessage}
247+
onSendNow={onSendQueuedMessage}
248+
onEdit={handleEditQueued}
249+
/>
250+
<UserInput
251+
ref={userInputRef}
252+
onSubmit={onSubmit}
253+
isSending={isStreamActive}
254+
onStopGeneration={onStopGeneration}
255+
isInitialView={false}
256+
userId={userId}
257+
onContextAdd={onContextAdd}
258+
onContextRemove={onContextRemove}
259+
onSendQueuedHead={handleSendQueuedHead}
260+
onEditQueuedTail={handleEditQueuedTail}
261+
draftScopeKey={draftScopeKey}
262+
/>
263+
</div>
254264
</div>
255-
</div>
265+
)}
256266
</div>
257267
)
258268
}

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -211,12 +211,19 @@ export const ResourceContent = memo(function ResourceContent({
211211
interface ResourceActionsProps {
212212
workspaceId: string
213213
resource: MothershipResource
214+
chatId?: string
214215
}
215216

216-
export function ResourceActions({ workspaceId, resource }: ResourceActionsProps) {
217+
export function ResourceActions({ workspaceId, resource, chatId }: ResourceActionsProps) {
217218
switch (resource.type) {
218219
case 'workflow':
219-
return <EmbeddedWorkflowActions workspaceId={workspaceId} workflowId={resource.id} />
220+
return (
221+
<EmbeddedWorkflowActions
222+
workspaceId={workspaceId}
223+
workflowId={resource.id}
224+
chatId={chatId}
225+
/>
226+
)
220227
case 'file':
221228
return <EmbeddedFileActions workspaceId={workspaceId} fileId={resource.id} />
222229
case 'knowledgebase':
@@ -244,9 +251,14 @@ export function ResourceActions({ workspaceId, resource }: ResourceActionsProps)
244251
interface EmbeddedWorkflowActionsProps {
245252
workspaceId: string
246253
workflowId: string
254+
chatId?: string
247255
}
248256

249-
export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWorkflowActionsProps) {
257+
export function EmbeddedWorkflowActions({
258+
workspaceId,
259+
workflowId,
260+
chatId,
261+
}: EmbeddedWorkflowActionsProps) {
250262
const router = useRouter()
251263
const { navigateToSettings } = useSettingsNavigation()
252264
const { userPermissions: effectivePermissions } = useWorkspacePermissionsContext()
@@ -284,7 +296,10 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
284296
}
285297

286298
const handleOpenWorkflow = () => {
287-
window.open(`/workspace/${workspaceId}/w/${workflowId}`, '_blank')
299+
const url = chatId
300+
? `/workspace/${workspaceId}/w/${workflowId}?chatId=${encodeURIComponent(chatId)}`
301+
: `/workspace/${workspaceId}/w/${workflowId}`
302+
window.open(url, '_blank')
288303
}
289304

290305
return (

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,9 @@ export const MothershipView = memo(
117117
onReorderResources={onReorderResources}
118118
onCollapse={onCollapse}
119119
actions={
120-
active ? <ResourceActions workspaceId={workspaceId} resource={active} /> : null
120+
active ? (
121+
<ResourceActions workspaceId={workspaceId} resource={active} chatId={chatId} />
122+
) : null
121123
}
122124
previewMode={isActivePreviewable ? previewMode : undefined}
123125
onCyclePreviewMode={isActivePreviewable ? handleCyclePreview : undefined}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createLogger } from '@sim/logger'
55
import { toError } from '@sim/utils/errors'
66
import { useQueryClient } from '@tanstack/react-query'
77
import { History, Plus } from 'lucide-react'
8-
import { useParams, useRouter } from 'next/navigation'
8+
import { useParams, useRouter, useSearchParams } from 'next/navigation'
99
import { usePostHog } from 'posthog-js/react'
1010
import { useShallow } from 'zustand/react/shallow'
1111
import {
@@ -118,7 +118,9 @@ interface PanelProps {
118118
export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: PanelProps = {}) {
119119
const router = useRouter()
120120
const params = useParams()
121+
const searchParams = useSearchParams()
121122
const workspaceId = propWorkspaceId ?? (params.workspaceId as string)
123+
const urlChatIdParam = searchParams?.get('chatId') ?? null
122124

123125
const posthog = usePostHog()
124126
const posthogRef = useRef(posthog)
@@ -256,6 +258,21 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
256258
[copilotChatId, copilotChatList]
257259
)
258260

261+
/**
262+
* A chat is read-only on this workflow page when it doesn't natively
263+
* belong to the active workflow — currently the case for Mothership
264+
* chats whose `workflowId` is null but whose `resources` reference this
265+
* workflow. Continuing the conversation would route through the
266+
* workflow copilot agent rather than the original Mothership context,
267+
* so we surface the history without the input.
268+
*/
269+
const isCopilotChatReadOnly = useMemo(() => {
270+
if (!copilotChatId || !activeWorkflowId) return false
271+
const chat = copilotChatList.find((c) => c.id === copilotChatId)
272+
if (!chat) return false
273+
return chat.workflowId !== activeWorkflowId
274+
}, [copilotChatId, copilotChatList, activeWorkflowId])
275+
259276
const queryClient = useQueryClient()
260277
const loadCopilotChats = useCallback(() => {
261278
if (!activeWorkflowId) return
@@ -264,7 +281,10 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
264281

265282
// Auto-select most recent on first list arrival per workflow, and drop a
266283
// selection that no longer matches anything in the current list (e.g. the
267-
// chat was deleted in another tab).
284+
// chat was deleted in another tab). When a `?chatId=` param is present in
285+
// the URL (e.g. after clicking "Open Workflow" from a Mothership task),
286+
// prefer that chat over the most recent so the original conversation is
287+
// shown right away.
268288
const autoSelectAttemptedForRef = useRef<Set<string>>(new Set())
269289
useEffect(() => {
270290
if (!activeWorkflowId) return
@@ -278,8 +298,12 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
278298
if (autoSelectAttemptedForRef.current.has(activeWorkflowId)) return
279299
if (copilotChatList.length === 0) return
280300
autoSelectAttemptedForRef.current.add(activeWorkflowId)
281-
setCopilotChatId(copilotChatList[0].id)
282-
}, [copilotChatList, copilotChatId, activeWorkflowId, setCopilotChatId])
301+
const preferred =
302+
urlChatIdParam && copilotChatList.find((c) => c.id === urlChatIdParam)
303+
? urlChatIdParam
304+
: copilotChatList[0].id
305+
setCopilotChatId(preferred)
306+
}, [copilotChatList, copilotChatId, activeWorkflowId, setCopilotChatId, urlChatIdParam])
283307

284308
useEffect(() => {
285309
posthogRef.current = posthog
@@ -444,6 +468,17 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
444468
setHasHydrated(true)
445469
}, [setHasHydrated])
446470

471+
/**
472+
* If the workflow page was opened with `?chatId=`, surface the copilot
473+
* tab so the linked conversation is visible without an extra click.
474+
*/
475+
const chatIdParamHandledRef = useRef(false)
476+
useEffect(() => {
477+
if (chatIdParamHandledRef.current || !urlChatIdParam) return
478+
chatIdParamHandledRef.current = true
479+
setActiveTab('copilot')
480+
}, [urlChatIdParam, setActiveTab])
481+
447482
useEffect(() => {
448483
const handler = (e: Event) => {
449484
const message = (e as CustomEvent<{ message: string }>).detail?.message
@@ -890,6 +925,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
890925
userId={session?.user?.id}
891926
chatId={copilotResolvedChatId}
892927
layout='copilot-view'
928+
readOnly={isCopilotChatReadOnly}
893929
/>
894930
</div>
895931
)}

apps/sim/hooks/queries/copilot-chats.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ async function fetchCopilotChats(
1717
): Promise<CopilotChatListItem[]> {
1818
try {
1919
const data = await requestJson(listCopilotChatsContract, { signal })
20-
return data.chats.filter((c) => c.workflowId === workflowId)
20+
return data.chats.filter(
21+
(c) =>
22+
c.workflowId === workflowId ||
23+
c.resources?.some((r) => r.type === 'workflow' && r.id === workflowId)
24+
)
2125
} catch (error) {
2226
if (error instanceof ApiClientError) return []
2327
throw error

apps/sim/lib/api/contracts/copilot.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ const copilotResourceTypeSchema = z.enum([
112112
'log',
113113
])
114114

115+
const copilotChatResourceSchema = z.object({
116+
type: copilotResourceTypeSchema,
117+
id: z.string(),
118+
title: z.string(),
119+
})
120+
115121
export const addCopilotChatResourceBodySchema = z.object({
116122
chatId: z.string(),
117123
resource: z.object({
@@ -301,6 +307,7 @@ export const copilotChatListItemSchema = z.object({
301307
workspaceId: z.string().nullable().optional(),
302308
activeStreamId: z.string().nullable(),
303309
updatedAt: z.string().nullable(),
310+
resources: z.array(copilotChatResourceSchema).optional(),
304311
})
305312
export type CopilotChatListItem = z.output<typeof copilotChatListItemSchema>
306313

@@ -378,12 +385,6 @@ const copilotCheckpointSchema = z.object({
378385
updatedAt: z.string().nullable(),
379386
})
380387

381-
const copilotChatResourceSchema = z.object({
382-
type: copilotResourceTypeSchema,
383-
id: z.string(),
384-
title: z.string(),
385-
})
386-
387388
const copilotAvailableModelSchema = z.object({
388389
id: z.string(),
389390
friendlyName: z.string(),

0 commit comments

Comments
 (0)