Skip to content

Commit 9ba51f9

Browse files
authored
fix(chat): stop chats storing a resource they can never send with (#6344)
* fix(chat): stop chats storing a resource they can never send with A chat resource persisted with a blank id made every later message fail: the write contract accepted `id: ''` while the send schema required `min(1)`, so the request 400d before a stream existed and the client's reconnect 404d. The tab could not be removed either, since the delete route requires a non-empty id. Twelve production chats were in this state. The id came from an agent-written file chip that carried only a filename: the client filled the missing id with `''` when the file was absent from its list, which it always is for a file the agent just created. - model the unresolved state (`WorkspaceResourceRef`) instead of faking an id, and resolve chip refs at one choke point that may refuse - close the stale-cache race by fetching the file list before giving up, so clicking a just-created file opens it instead of doing nothing - reject blank ids at the stream, write and send boundaries, and drop them wherever stored resources are read, which self-heals affected chats - collapse the 5-6 duplicate POSTs every resource add was firing - log rejected chat bodies, which previously left no trace at all * fix(chat): require a file chip's reference to resolve before opening it A rendered link collapses a resource's id and path into one href, so the click handler cannot tell them apart. Classifying on a separator got a bare filename in `path` wrong, and the resolver then trusted it as an id — opening and persisting a tab pointing at nothing. Drop the classifier and let the resolver try each candidate as an id, a VFS path and a unique name. A file ref must now match a record the workspace actually has; the stale-list case is covered by the refetch, so an id that never resolves was never an id. * fix(chat): tell the user when a resource chip resolves to nothing The chip renders as a button with a hover state, so refusing to open it silently reads as a broken control. Say what happened instead. * fix(chat): do not report an unreachable workspace as a missing file A failed refetch and a successful one that found nothing were both collapsed to an empty list, so a network blip told the user the file does not exist. Keep the two apart and say which happened.
1 parent b04fee8 commit 9ba51f9

20 files changed

Lines changed: 489 additions & 97 deletions

File tree

apps/sim/app/api/copilot/chat/resources/route.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ import {
1919
import type { ChatResource } from '@/lib/copilot/resources/persistence'
2020
import {
2121
canonicalizeDesktopSessionResource,
22-
canonicalizeDesktopSessionResources,
2322
GENERIC_RESOURCE_TITLES,
23+
sanitizeChatResources,
2424
} from '@/lib/copilot/resources/types'
2525
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2626

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

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

144-
const existing = canonicalizeDesktopSessionResources(
144+
const existing = sanitizeChatResources(
145145
Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : []
146146
)
147-
const canonicalOrder = canonicalizeDesktopSessionResources(newOrder)
147+
const canonicalOrder = sanitizeChatResources(newOrder)
148148
const existingKeys = new Set(existing.map((r) => `${r.type}:${r.id}`))
149149
const newKeys = new Set(canonicalOrder.map((r) => `${r.type}:${r.id}`))
150150

apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,7 @@ import {
2929
createUnauthorizedResponse,
3030
} from '@/lib/copilot/request/http'
3131
import { removeChatResources } from '@/lib/copilot/resources/persistence'
32-
import {
33-
canonicalizeDesktopSessionResources,
34-
type MothershipResource,
35-
} from '@/lib/copilot/resources/types'
32+
import { type MothershipResource, sanitizeChatResources } from '@/lib/copilot/resources/types'
3633
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
3734
import { env } from '@/lib/core/config/env'
3835
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -118,7 +115,7 @@ export const POST = withRouteHandler(
118115
// file resources whose chat-owned file is NOT copied (uploads born
119116
// after the cut) are dropped in the rewrite below; everything else is
120117
// copied.
121-
const parentResources = canonicalizeDesktopSessionResources(
118+
const parentResources = sanitizeChatResources(
122119
Array.isArray(parent.resources) ? (parent.resources as MothershipResource[]) : []
123120
)
124121

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
useRef,
1111
} from 'react'
1212
import { noop } from '@sim/utils/helpers'
13-
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
13+
import type { WorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/types'
1414
import type { ChatContext } from '@/stores/panel'
1515

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

4040
const ChatSurfaceContext = createContext<ChatSurfaceContextValue>({
@@ -48,7 +48,7 @@ interface ChatSurfaceProviderProps {
4848
userId?: string
4949
onContextAdd?: (context: ChatContext) => void
5050
onContextRemove?: (context: ChatContext, remaining: ChatContext[]) => void
51-
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
51+
onWorkspaceResourceSelect?: (resource: WorkspaceResourceRef) => void
5252
children: ReactNode
5353
}
5454

@@ -82,7 +82,7 @@ export function ChatSurfaceProvider({
8282
const stableOnContextRemove = useCallback((context: ChatContext, remaining: ChatContext[]) => {
8383
onContextRemoveRef.current?.(context, remaining)
8484
}, [])
85-
const stableOnWorkspaceResourceSelect = useCallback((resource: MothershipResource) => {
85+
const stableOnWorkspaceResourceSelect = useCallback((resource: WorkspaceResourceRef) => {
8686
onWorkspaceResourceSelectRef.current?.(resource)
8787
}, [])
8888

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import {
2020
parseSpecialTags,
2121
SpecialTags,
2222
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
23-
import type { ChatContextKind, MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
23+
import type {
24+
ChatContextKind,
25+
WorkspaceResourceRef,
26+
} from '@/app/workspace/[workspaceId]/home/types'
2427
import { useSmoothText } from '@/hooks/use-smooth-text'
2528
import { sanitizeChatDisplayContent } from './chat-sanitize'
2629
import { ExternalLink, externalLinkHostname } from './external-link'
@@ -278,6 +281,9 @@ const MARKDOWN_COMPONENTS = {
278281
e.preventDefault()
279282
if (!type || !ref) return
280283
const linkText = label || ref
284+
// A file link carries whichever the tag had (`path ?? id`) with no
285+
// way to tell them apart here, so it is forwarded as-is and the
286+
// resolver tries every interpretation against the real file list.
281287
window.dispatchEvent(
282288
new CustomEvent('wsres-click', {
283289
detail:
@@ -393,7 +399,7 @@ interface ChatContentProps {
393399
questionAnswers?: string[]
394400
onOptionSelect?: (id: string) => void
395401
onQuestionDismiss?: () => void
396-
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
402+
onWorkspaceResourceSelect?: (resource: WorkspaceResourceRef) => void
397403
onRevealStateChange?: (isRevealing: boolean) => void
398404
/** Reports whether this segment is actively painting text. */
399405
onStreamActivityChange?: (active: boolean) => void
@@ -520,10 +526,12 @@ function ChatContentInner({
520526
useEffect(() => {
521527
const handler = (e: Event) => {
522528
const { type, id, path, title } = (e as CustomEvent).detail
529+
// A link built from a path carries no id. Forward what the tag actually
530+
// had; the select handler resolves it rather than guessing here.
523531
onWorkspaceResourceSelectRef.current?.({
524532
type,
525-
id: id ?? '',
526-
path,
533+
...(id ? { id } : {}),
534+
...(path ? { path } : {}),
527535
title: title || id || path || '',
528536
})
529537
}

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import { useSession } from '@/lib/auth/auth-client'
2121
import { buildHostedUpgradeUrl, HOSTED_BILLING_SETTINGS_URL } from '@/lib/billing/upgrade-reasons'
2222
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
2323
import { isBrowserAgentAvailable, sendBrowserPanelAction } from '@/lib/browser-agent/transport'
24-
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
2524
import { isHosted } from '@/lib/core/config/env-flags'
2625
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
2726
import { getDesktopBridge } from '@/lib/desktop'
@@ -39,6 +38,7 @@ import { QuestionDisplay } from '@/app/workspace/[workspaceId]/home/components/m
3938
import type {
4039
ChatMessageContext,
4140
MothershipResource,
41+
WorkspaceResourceRef,
4242
} from '@/app/workspace/[workspaceId]/home/types'
4343
// Deep import, not the barrel: the barrel also re-exports
4444
// ConnectServiceAccountModal, and that edge would pull the modal into this
@@ -54,6 +54,7 @@ import {
5454
} from '@/hooks/queries/environment'
5555
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
5656
import { useTablesList } from '@/hooks/queries/tables'
57+
import { findWorkspaceFileByPath } from '@/hooks/queries/utils/find-workspace-file-by-src'
5758
import { useWorkflows } from '@/hooks/queries/workflows'
5859
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
5960
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
@@ -1300,7 +1301,7 @@ interface SpecialTagsProps {
13001301
questionAnswers?: string[]
13011302
onOptionSelect?: (id: string) => void
13021303
onQuestionDismiss?: () => void
1303-
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
1304+
onWorkspaceResourceSelect?: (resource: WorkspaceResourceRef) => void
13041305
}
13051306

13061307
/**
@@ -1455,23 +1456,17 @@ export function WorkspaceResourceDisplay({
14551456
onSelect,
14561457
}: {
14571458
data: WorkspaceResourceTagData
1458-
onSelect?: (resource: MothershipResource) => void
1459+
onSelect?: (resource: WorkspaceResourceRef) => void
14591460
}) {
14601461
const { workspaceId } = useParams<{ workspaceId: string }>()
14611462
const { data: workflows = [] } = useWorkflows(workspaceId)
14621463
const { data: tables = [] } = useTablesList(workspaceId)
14631464
const { data: files = [] } = useWorkspaceFiles(workspaceId)
14641465
const { data: knowledgeBases = [] } = useKnowledgeBasesQuery(workspaceId)
14651466

1466-
const resource = useMemo<MothershipResource>(() => {
1467+
const resource = useMemo<WorkspaceResourceRef>(() => {
14671468
const fileFromPath =
1468-
data.type === 'file' && data.path
1469-
? files.find(
1470-
(file) =>
1471-
canonicalWorkspaceFilePath({ folderPath: file.folderPath, name: file.name }) ===
1472-
data.path
1473-
)
1474-
: undefined
1469+
data.type === 'file' ? findWorkspaceFileByPath(files, data.path) : undefined
14751470
const title =
14761471
data.type === 'workflow'
14771472
? (workflows.find((workflow) => workflow.id === data.id)?.name ??
@@ -1487,9 +1482,10 @@ export function WorkspaceResourceDisplay({
14871482
: (knowledgeBases.find((knowledgeBase) => knowledgeBase.id === data.id)?.name ??
14881483
fallbackWorkspaceResourceTitle(data.type))
14891484

1485+
const id = data.id ?? fileFromPath?.id
14901486
return {
14911487
type: toMothershipResourceType(data.type),
1492-
id: data.id ?? fileFromPath?.id ?? data.path ?? '',
1488+
...(id ? { id } : {}),
14931489
title,
14941490
...(data.type === 'file' && data.path ? { path: data.path } : {}),
14951491
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ import type {
3535
ChatMessageContext,
3636
ContentBlock,
3737
FileAttachmentForApi,
38-
MothershipResource,
3938
QueuedMessage,
39+
WorkspaceResourceRef,
4040
} from '@/app/workspace/[workspaceId]/home/types'
4141
import { useAutoScroll } from '@/hooks/use-auto-scroll'
4242
import type { ChatContext } from '@/stores/panel'
@@ -70,7 +70,7 @@ interface MothershipChatProps {
7070
* `ChatSurfaceContextValue`, which this forwards to.
7171
*/
7272
onContextRemove?: (context: ChatContext, remaining: ChatContext[]) => void
73-
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
73+
onWorkspaceResourceSelect?: (resource: WorkspaceResourceRef) => void
7474
draftScopeKey?: string
7575
layout?: 'mothership-view' | 'copilot-view'
7676
initialScrollBlocked?: boolean

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 56 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,15 @@ import {
1212
useState,
1313
useSyncExternalStore,
1414
} from 'react'
15-
import { Button, cn } from '@sim/emcn'
15+
import { Button, cn, toast } from '@sim/emcn'
1616
import { PanelLeft } from '@sim/emcn/icons'
1717
import { createLogger } from '@sim/logger'
18+
import { useQueryClient } from '@tanstack/react-query'
1819
import { useParams, useRouter } from 'next/navigation'
1920
import { useQueryState } from 'nuqs'
2021
import { usePostHog } from 'posthog-js/react'
2122
import { requestJson } from '@/lib/api/client/request'
2223
import { createWorkflowContract } from '@/lib/api/contracts'
23-
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
2424
import {
2525
LandingPromptStorage,
2626
type LandingWorkflowSeed,
@@ -36,14 +36,15 @@ import {
3636
import { captureEvent } from '@/lib/posthog/client'
3737
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
3838
import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
39+
import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref'
3940
import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params'
4041
import { useFolders } from '@/hooks/queries/folders'
4142
import {
4243
useMarkMothershipChatRead,
4344
useMothershipChatHistory,
4445
} from '@/hooks/queries/mothership-chats'
4546
import { useWorkflows } from '@/hooks/queries/workflows'
46-
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
47+
import { getWorkspaceFilesQueryOptions, useWorkspaceFiles } from '@/hooks/queries/workspace-files'
4748
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
4849
import type { ChatContext } from '@/stores/panel'
4950
import {
@@ -56,7 +57,12 @@ import {
5657
type UserInputHandle,
5758
} from './components'
5859
import { getMothershipUseChatOptions, useChat, useMothershipResize } from './hooks'
59-
import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types'
60+
import type {
61+
FileAttachmentForApi,
62+
MothershipResource,
63+
MothershipResourceType,
64+
WorkspaceResourceRef,
65+
} from './types'
6066

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

435-
const resolveFileResource = useCallback(
436-
(resource: MothershipResource): MothershipResource => {
437-
if (resource.type !== 'file') return resource
438-
439-
const reference = (resource.path || resource.id).trim()
440-
441-
const file = workspaceFiles.find((candidate) => {
442-
const candidatePath = canonicalWorkspaceFilePath({
443-
folderPath: candidate.folderPath,
444-
name: candidate.name,
445-
})
446-
return candidate.id === reference || candidatePath === reference
447-
})
448-
449-
if (!file) return resource
450-
return {
451-
...resource,
452-
id: file.id,
453-
title: resource.title || file.name,
454-
}
455-
},
456-
[workspaceFiles]
457-
)
458-
459-
function handleWorkspaceResourceSelect(resource: MothershipResource) {
460-
const resolvedResource = resolveFileResource(resource)
461-
const wasAdded = addResource(resolvedResource)
442+
function openWorkspaceResource(resource: MothershipResource) {
443+
const wasAdded = addResource(resource)
462444
if (!wasAdded) {
463-
setActiveResourceId(resolvedResource.id)
445+
setActiveResourceId(resource.id)
464446
}
465447
handleResourceEvent()
466448
}
467449

450+
/**
451+
* Opens the resource a message chip points at, resolving it first. A chip may
452+
* carry only a filename — the agent names a file before the client's file
453+
* list knows it exists — so one forced refetch closes that window. What still
454+
* resolves to nothing opens nothing, rather than a tab that cannot be
455+
* viewed or removed.
456+
*/
457+
async function handleWorkspaceResourceSelect(ref: WorkspaceResourceRef) {
458+
const immediate = resolveWorkspaceResourceRef(ref, workspaceFiles)
459+
if (immediate) {
460+
openWorkspaceResource(immediate)
461+
return
462+
}
463+
if (ref.type !== 'file') return
464+
465+
// `staleTime: 0` forces the fetch this branch exists for — the cached list
466+
// is what already failed to resolve. `fetchQuery` rejects on error and this
467+
// handler is invoked as a void callback, so failure becomes null rather
468+
// than an unhandled rejection — and stays distinct from an empty list, so
469+
// "we could not look" is never reported as "it is not there".
470+
const files = await queryClient
471+
.fetchQuery({ ...getWorkspaceFilesQueryOptions(workspaceId), staleTime: 0 })
472+
.catch(() => null)
473+
const resolved = files && resolveWorkspaceResourceRef(ref, files)
474+
if (resolved) {
475+
openWorkspaceResource(resolved)
476+
return
477+
}
478+
// The chip looks clickable, so refusing silently reads as a broken button.
479+
toast.error(
480+
files
481+
? `Couldn't find "${ref.title}" in this workspace`
482+
: `Couldn't open "${ref.title}" — check your connection and try again`
483+
)
484+
logger.warn('Ignored a resource chip that did not resolve', {
485+
type: ref.type,
486+
title: ref.title,
487+
hasPath: Boolean(ref.path),
488+
reachedWorkspace: files !== null,
489+
})
490+
}
491+
468492
const hasMessages = messages.length > 0
469493
const showChatSkeleton = Boolean(chatId) && !hasMessages && isChatHistoryPending
470494
const draftScopeKey = `${workspaceId}:${chatId ?? 'new'}`

0 commit comments

Comments
 (0)