Skip to content
Merged
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
8 changes: 4 additions & 4 deletions apps/sim/app/api/copilot/chat/resources/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import {
import type { ChatResource } from '@/lib/copilot/resources/persistence'
import {
canonicalizeDesktopSessionResource,
canonicalizeDesktopSessionResources,
GENERIC_RESOURCE_TITLES,
sanitizeChatResources,
} from '@/lib/copilot/resources/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

Expand Down Expand Up @@ -67,7 +67,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
return createNotFoundResponse('Chat not found or unauthorized')
}

const existing = canonicalizeDesktopSessionResources(
const existing = sanitizeChatResources(
Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : []
)
const key = `${resource.type}:${resource.id}`
Expand Down Expand Up @@ -141,10 +141,10 @@ export const PATCH = withRouteHandler(async (req: NextRequest) => {
return createNotFoundResponse('Chat not found or unauthorized')
}

const existing = canonicalizeDesktopSessionResources(
const existing = sanitizeChatResources(
Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : []
)
const canonicalOrder = canonicalizeDesktopSessionResources(newOrder)
const canonicalOrder = sanitizeChatResources(newOrder)
const existingKeys = new Set(existing.map((r) => `${r.type}:${r.id}`))
const newKeys = new Set(canonicalOrder.map((r) => `${r.type}:${r.id}`))

Expand Down
7 changes: 2 additions & 5 deletions apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,7 @@ import {
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import { removeChatResources } from '@/lib/copilot/resources/persistence'
import {
canonicalizeDesktopSessionResources,
type MothershipResource,
} from '@/lib/copilot/resources/types'
import { type MothershipResource, sanitizeChatResources } from '@/lib/copilot/resources/types'
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
Expand Down Expand Up @@ -118,7 +115,7 @@ export const POST = withRouteHandler(
// file resources whose chat-owned file is NOT copied (uploads born
// after the cut) are dropped in the rewrite below; everything else is
// copied.
const parentResources = canonicalizeDesktopSessionResources(
const parentResources = sanitizeChatResources(
Array.isArray(parent.resources) ? (parent.resources as MothershipResource[]) : []
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
useRef,
} from 'react'
import { noop } from '@sim/utils/helpers'
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import type { WorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/types'
import type { ChatContext } from '@/stores/panel'

/**
Expand All @@ -34,7 +34,7 @@ interface ChatSurfaceContextValue {
*/
onContextRemove: (context: ChatContext, remaining: ChatContext[]) => void
/** Opens a workspace resource referenced from rendered message content. */
onWorkspaceResourceSelect: (resource: MothershipResource) => void
onWorkspaceResourceSelect: (resource: WorkspaceResourceRef) => void
}

const ChatSurfaceContext = createContext<ChatSurfaceContextValue>({
Expand All @@ -48,7 +48,7 @@ interface ChatSurfaceProviderProps {
userId?: string
onContextAdd?: (context: ChatContext) => void
onContextRemove?: (context: ChatContext, remaining: ChatContext[]) => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
onWorkspaceResourceSelect?: (resource: WorkspaceResourceRef) => void
children: ReactNode
}

Expand Down Expand Up @@ -82,7 +82,7 @@ export function ChatSurfaceProvider({
const stableOnContextRemove = useCallback((context: ChatContext, remaining: ChatContext[]) => {
onContextRemoveRef.current?.(context, remaining)
}, [])
const stableOnWorkspaceResourceSelect = useCallback((resource: MothershipResource) => {
const stableOnWorkspaceResourceSelect = useCallback((resource: WorkspaceResourceRef) => {
onWorkspaceResourceSelectRef.current?.(resource)
}, [])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ import {
parseSpecialTags,
SpecialTags,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import type { ChatContextKind, MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import type {
ChatContextKind,
WorkspaceResourceRef,
} from '@/app/workspace/[workspaceId]/home/types'
import { useSmoothText } from '@/hooks/use-smooth-text'
import { sanitizeChatDisplayContent } from './chat-sanitize'
import { ExternalLink, externalLinkHostname } from './external-link'
Expand Down Expand Up @@ -278,6 +281,9 @@ const MARKDOWN_COMPONENTS = {
e.preventDefault()
if (!type || !ref) return
const linkText = label || ref
// A file link carries whichever the tag had (`path ?? id`) with no
// way to tell them apart here, so it is forwarded as-is and the
// resolver tries every interpretation against the real file list.
window.dispatchEvent(
new CustomEvent('wsres-click', {
detail:
Expand Down Expand Up @@ -393,7 +399,7 @@ interface ChatContentProps {
questionAnswers?: string[]
onOptionSelect?: (id: string) => void
onQuestionDismiss?: () => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
onWorkspaceResourceSelect?: (resource: WorkspaceResourceRef) => void
onRevealStateChange?: (isRevealing: boolean) => void
/** Reports whether this segment is actively painting text. */
onStreamActivityChange?: (active: boolean) => void
Expand Down Expand Up @@ -520,10 +526,12 @@ function ChatContentInner({
useEffect(() => {
const handler = (e: Event) => {
const { type, id, path, title } = (e as CustomEvent).detail
// A link built from a path carries no id. Forward what the tag actually
// had; the select handler resolves it rather than guessing here.
onWorkspaceResourceSelectRef.current?.({
type,
id: id ?? '',
path,
...(id ? { id } : {}),
...(path ? { path } : {}),
title: title || id || path || '',
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import { useSession } from '@/lib/auth/auth-client'
import { buildHostedUpgradeUrl, HOSTED_BILLING_SETTINGS_URL } from '@/lib/billing/upgrade-reasons'
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
import { isBrowserAgentAvailable, sendBrowserPanelAction } from '@/lib/browser-agent/transport'
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
import { isHosted } from '@/lib/core/config/env-flags'
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
import { getDesktopBridge } from '@/lib/desktop'
Expand All @@ -39,6 +38,7 @@ import { QuestionDisplay } from '@/app/workspace/[workspaceId]/home/components/m
import type {
ChatMessageContext,
MothershipResource,
WorkspaceResourceRef,
} from '@/app/workspace/[workspaceId]/home/types'
// Deep import, not the barrel: the barrel also re-exports
// ConnectServiceAccountModal, and that edge would pull the modal into this
Expand All @@ -54,6 +54,7 @@ import {
} from '@/hooks/queries/environment'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useTablesList } from '@/hooks/queries/tables'
import { findWorkspaceFileByPath } from '@/hooks/queries/utils/find-workspace-file-by-src'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
Expand Down Expand Up @@ -1300,7 +1301,7 @@ interface SpecialTagsProps {
questionAnswers?: string[]
onOptionSelect?: (id: string) => void
onQuestionDismiss?: () => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
onWorkspaceResourceSelect?: (resource: WorkspaceResourceRef) => void
}

/**
Expand Down Expand Up @@ -1455,23 +1456,17 @@ export function WorkspaceResourceDisplay({
onSelect,
}: {
data: WorkspaceResourceTagData
onSelect?: (resource: MothershipResource) => void
onSelect?: (resource: WorkspaceResourceRef) => void
}) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const { data: workflows = [] } = useWorkflows(workspaceId)
const { data: tables = [] } = useTablesList(workspaceId)
const { data: files = [] } = useWorkspaceFiles(workspaceId)
const { data: knowledgeBases = [] } = useKnowledgeBasesQuery(workspaceId)

const resource = useMemo<MothershipResource>(() => {
const resource = useMemo<WorkspaceResourceRef>(() => {
const fileFromPath =
data.type === 'file' && data.path
? files.find(
(file) =>
canonicalWorkspaceFilePath({ folderPath: file.folderPath, name: file.name }) ===
data.path
)
: undefined
data.type === 'file' ? findWorkspaceFileByPath(files, data.path) : undefined
const title =
data.type === 'workflow'
? (workflows.find((workflow) => workflow.id === data.id)?.name ??
Expand All @@ -1487,9 +1482,10 @@ export function WorkspaceResourceDisplay({
: (knowledgeBases.find((knowledgeBase) => knowledgeBase.id === data.id)?.name ??
fallbackWorkspaceResourceTitle(data.type))

const id = data.id ?? fileFromPath?.id
return {
type: toMothershipResourceType(data.type),
id: data.id ?? fileFromPath?.id ?? data.path ?? '',
...(id ? { id } : {}),
title,
...(data.type === 'file' && data.path ? { path: data.path } : {}),
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ import type {
ChatMessageContext,
ContentBlock,
FileAttachmentForApi,
MothershipResource,
QueuedMessage,
WorkspaceResourceRef,
} from '@/app/workspace/[workspaceId]/home/types'
import { useAutoScroll } from '@/hooks/use-auto-scroll'
import type { ChatContext } from '@/stores/panel'
Expand Down Expand Up @@ -70,7 +70,7 @@ interface MothershipChatProps {
* `ChatSurfaceContextValue`, which this forwards to.
*/
onContextRemove?: (context: ChatContext, remaining: ChatContext[]) => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
onWorkspaceResourceSelect?: (resource: WorkspaceResourceRef) => void
draftScopeKey?: string
layout?: 'mothership-view' | 'copilot-view'
initialScrollBlocked?: boolean
Expand Down
88 changes: 56 additions & 32 deletions apps/sim/app/workspace/[workspaceId]/home/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ import {
useState,
useSyncExternalStore,
} from 'react'
import { Button, cn } from '@sim/emcn'
import { Button, cn, toast } from '@sim/emcn'
import { PanelLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
import { usePostHog } from 'posthog-js/react'
import { requestJson } from '@/lib/api/client/request'
import { createWorkflowContract } from '@/lib/api/contracts'
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
import {
LandingPromptStorage,
type LandingWorkflowSeed,
Expand All @@ -36,14 +36,15 @@ import {
import { captureEvent } from '@/lib/posthog/client'
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref'
import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params'
import { useFolders } from '@/hooks/queries/folders'
import {
useMarkMothershipChatRead,
useMothershipChatHistory,
} from '@/hooks/queries/mothership-chats'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { getWorkspaceFilesQueryOptions, useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
import type { ChatContext } from '@/stores/panel'
import {
Expand All @@ -56,7 +57,12 @@ import {
type UserInputHandle,
} from './components'
import { getMothershipUseChatOptions, useChat, useMothershipResize } from './hooks'
import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types'
import type {
FileAttachmentForApi,
MothershipResource,
MothershipResourceType,
WorkspaceResourceRef,
} from './types'

const logger = createLogger('Home')
const subscribeToDesktopApp = () => () => {}
Expand Down Expand Up @@ -90,6 +96,7 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
)
const { workspaceId } = useParams<{ workspaceId: string }>()
const router = useRouter()
const queryClient = useQueryClient()
/**
* URL is the single source of truth for the selected resource. `Home` renders
* client-side, so nuqs reads `?resource=` from the URL on mount — the same
Expand Down Expand Up @@ -432,39 +439,56 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
removeResource(resolved.type, resolved.id)
}

const resolveFileResource = useCallback(
(resource: MothershipResource): MothershipResource => {
if (resource.type !== 'file') return resource

const reference = (resource.path || resource.id).trim()

const file = workspaceFiles.find((candidate) => {
const candidatePath = canonicalWorkspaceFilePath({
folderPath: candidate.folderPath,
name: candidate.name,
})
return candidate.id === reference || candidatePath === reference
})

if (!file) return resource
return {
...resource,
id: file.id,
title: resource.title || file.name,
}
},
[workspaceFiles]
)

function handleWorkspaceResourceSelect(resource: MothershipResource) {
const resolvedResource = resolveFileResource(resource)
const wasAdded = addResource(resolvedResource)
function openWorkspaceResource(resource: MothershipResource) {
const wasAdded = addResource(resource)
if (!wasAdded) {
setActiveResourceId(resolvedResource.id)
setActiveResourceId(resource.id)
}
handleResourceEvent()
}

/**
* Opens the resource a message chip points at, resolving it first. A chip may
* carry only a filename — the agent names a file before the client's file
* list knows it exists — so one forced refetch closes that window. What still
* resolves to nothing opens nothing, rather than a tab that cannot be
* viewed or removed.
*/
async function handleWorkspaceResourceSelect(ref: WorkspaceResourceRef) {
const immediate = resolveWorkspaceResourceRef(ref, workspaceFiles)
if (immediate) {
openWorkspaceResource(immediate)
return
}
if (ref.type !== 'file') return

// `staleTime: 0` forces the fetch this branch exists for — the cached list
// is what already failed to resolve. `fetchQuery` rejects on error and this
// handler is invoked as a void callback, so failure becomes null rather
// than an unhandled rejection — and stays distinct from an empty list, so
// "we could not look" is never reported as "it is not there".
const files = await queryClient
.fetchQuery({ ...getWorkspaceFilesQueryOptions(workspaceId), staleTime: 0 })
.catch(() => null)
const resolved = files && resolveWorkspaceResourceRef(ref, files)
if (resolved) {
openWorkspaceResource(resolved)
return
}
// The chip looks clickable, so refusing silently reads as a broken button.
toast.error(
files
? `Couldn't find "${ref.title}" in this workspace`
: `Couldn't open "${ref.title}" — check your connection and try again`
)
logger.warn('Ignored a resource chip that did not resolve', {
type: ref.type,
title: ref.title,
hasPath: Boolean(ref.path),
reachedWorkspace: files !== null,
})
}

const hasMessages = messages.length > 0
const showChatSkeleton = Boolean(chatId) && !hasMessages && isChatHistoryPending
const draftScopeKey = `${workspaceId}:${chatId ?? 'new'}`
Expand Down
Loading