Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 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,7 @@ export const GET = withRouteHandler(async (_request: NextRequest) => {
workspaceId: copilotChats.workspaceId,
activeStreamId: copilotChats.conversationId,
updatedAt: copilotChats.updatedAt,
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 @@ -52,6 +52,13 @@ interface MothershipChatProps {
animateInput?: boolean
onInputAnimationEnd?: () => void
className?: string
/**
* When true, hides the input footer so the conversation is shown as
* read-only history. Used when a chat is surfaced outside the context
* where it can be safely continued (e.g. a Mothership chat opened from
* a workflow page).
*/
readOnly?: boolean
}

const LAYOUT_STYLES = {
Expand Down Expand Up @@ -100,6 +107,7 @@ export function MothershipChat({
animateInput = false,
onInputAnimationEnd,
className,
readOnly = false,
}: MothershipChatProps) {
const styles = LAYOUT_STYLES[layout]
const isStreamActive = isSending || isReconnecting
Expand Down Expand Up @@ -227,32 +235,34 @@ export function MothershipChat({
)}
</div>

<div
className={cn(styles.footer, animateInput && 'animate-slide-in-bottom')}
onAnimationEnd={animateInput ? onInputAnimationEnd : undefined}
>
<div className={styles.footerInner}>
<QueuedMessages
messageQueue={messageQueue}
onRemove={onRemoveQueuedMessage}
onSendNow={onSendQueuedMessage}
onEdit={handleEditQueued}
/>
<UserInput
ref={userInputRef}
onSubmit={onSubmit}
isSending={isStreamActive}
onStopGeneration={onStopGeneration}
isInitialView={false}
userId={userId}
onContextAdd={onContextAdd}
onContextRemove={onContextRemove}
onSendQueuedHead={handleSendQueuedHead}
onEditQueuedTail={handleEditQueuedTail}
draftScopeKey={draftScopeKey}
/>
{!readOnly && (
<div
className={cn(styles.footer, animateInput && 'animate-slide-in-bottom')}
onAnimationEnd={animateInput ? onInputAnimationEnd : undefined}
>
<div className={styles.footerInner}>
<QueuedMessages
messageQueue={messageQueue}
onRemove={onRemoveQueuedMessage}
onSendNow={onSendQueuedMessage}
onEdit={handleEditQueued}
/>
<UserInput
ref={userInputRef}
onSubmit={onSubmit}
isSending={isStreamActive}
onStopGeneration={onStopGeneration}
isInitialView={false}
userId={userId}
onContextAdd={onContextAdd}
onContextRemove={onContextRemove}
onSendQueuedHead={handleSendQueuedHead}
onEditQueuedTail={handleEditQueuedTail}
draftScopeKey={draftScopeKey}
/>
</div>
</div>
</div>
)}
</div>
)
}
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}`
window.open(url, '_blank')
}

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 @@ -118,7 +118,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 @@ -256,6 +258,21 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
[copilotChatId, copilotChatList]
)

/**
* A chat is read-only on this workflow page when it doesn't natively
* belong to the active workflow — currently the case for Mothership
* chats whose `workflowId` is null but whose `resources` reference this
* workflow. Continuing the conversation would route through the
* workflow copilot agent rather than the original Mothership context,
* so we surface the history without the input.
*/
const isCopilotChatReadOnly = useMemo(() => {
if (!copilotChatId || !activeWorkflowId) return false
const chat = copilotChatList.find((c) => c.id === copilotChatId)
if (!chat) return false
return chat.workflowId !== activeWorkflowId
}, [copilotChatId, copilotChatList, activeWorkflowId])

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 Input footer briefly visible before read-only state resolves

isCopilotChatReadOnly returns false whenever copilotChatId is falsy, which is the case during the window between mount and the first auto-select (i.e., while copilotChatList is still loading). When the page opens with ?chatId=, the copilot tab is activated immediately by chatIdParamHandledRef, but copilotChatId isn't set until the list arrives and the auto-select effect fires. During that gap MothershipChat renders with readOnly={false}, briefly showing the input footer before it disappears once the list loads and copilotChatId is set to the Mothership chat. This could be prevented by initialising copilotChatId directly from urlChatIdParam instead of waiting for the list, or by letting isCopilotChatReadOnly return true optimistically when urlChatIdParam is set and copilotChatId is still unresolved.

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.

Moot now — the read-only behavior was removed in 6be5374 in favor of full continuation (panel swaps useChat to the Mothership branch when the selected chat is type: 'mothership'). There is no readOnly flicker because there is no readOnly path anymore.


const queryClient = useQueryClient()
const loadCopilotChats = useCallback(() => {
if (!activeWorkflowId) return
Expand All @@ -264,7 +281,10 @@ 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.
const autoSelectAttemptedForRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (!activeWorkflowId) return
Expand All @@ -278,8 +298,12 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
if (autoSelectAttemptedForRef.current.has(activeWorkflowId)) return
if (copilotChatList.length === 0) return
autoSelectAttemptedForRef.current.add(activeWorkflowId)
setCopilotChatId(copilotChatList[0].id)
}, [copilotChatList, copilotChatId, activeWorkflowId, setCopilotChatId])
const preferred =
urlChatIdParam && copilotChatList.find((c) => c.id === urlChatIdParam)
? urlChatIdParam
: copilotChatList[0].id
setCopilotChatId(preferred)
}, [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 @@ -444,6 +468,17 @@ 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.
*/
const chatIdParamHandledRef = useRef(false)
useEffect(() => {
if (chatIdParamHandledRef.current || !urlChatIdParam) return
chatIdParamHandledRef.current = true
setActiveTab('copilot')
}, [urlChatIdParam, setActiveTab])

useEffect(() => {
const handler = (e: Event) => {
const message = (e as CustomEvent<{ message: string }>).detail?.message
Expand Down Expand Up @@ -890,6 +925,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
userId={session?.user?.id}
chatId={copilotResolvedChatId}
layout='copilot-view'
readOnly={isCopilotChatReadOnly}
/>
</div>
)}
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
13 changes: 7 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,7 @@ export const copilotChatListItemSchema = z.object({
workspaceId: z.string().nullable().optional(),
activeStreamId: z.string().nullable(),
updatedAt: z.string().nullable(),
resources: z.array(copilotChatResourceSchema).optional(),
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
})
export type CopilotChatListItem = z.output<typeof copilotChatListItemSchema>

Expand Down Expand Up @@ -378,12 +385,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