fix(md): file streaming patch preview (#4465)

* fix(md): file streaming patch preview

* address comment
This commit is contained in:
Vikhyath Mondreti
2026-05-05 20:47:57 -07:00
committed by GitHub
parent 3a7928987e
commit 93c0202c30
5 changed files with 275 additions and 57 deletions
@@ -32,6 +32,7 @@ import {
RESOURCE_TAB_ICON_BUTTON_CLASS,
RESOURCE_TAB_ICON_CLASS,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
import { hasRenderableFilePreviewContent } from '@/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions'
import type {
GenericResourceData,
MothershipResource,
@@ -116,13 +117,19 @@ export const ResourceContent = memo(function ResourceContent({
const disableStreamingAutoScroll = previewSession?.operation === 'patch'
const rawPreviewText = previewSession?.previewText
const streamingPreviewText =
typeof rawPreviewText === 'string' && rawPreviewText.length > 0 ? rawPreviewText : undefined
previewSession &&
typeof rawPreviewText === 'string' &&
hasRenderableFilePreviewContent(previewSession)
? rawPreviewText
: undefined
const pendingOrStreamingFilePreviewText =
previewSession?.fileId === resource.id && typeof rawPreviewText === 'string'
previewSession?.fileId === resource.id &&
typeof rawPreviewText === 'string' &&
hasRenderableFilePreviewContent(previewSession)
? rawPreviewText
: undefined
if (previewSession && resource.id === 'streaming-file') {
if (resource.id === 'streaming-file') {
return (
<div className='flex h-full flex-col overflow-hidden'>
{streamingPreviewText !== undefined ? (
@@ -6,6 +6,7 @@ import { cn } from '@/lib/core/utils/cn'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import { RICH_PREVIEWABLE_EXTENSIONS } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import { hasRenderableFilePreviewContent } from '@/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions'
import type {
GenericResourceData,
MothershipResource,
@@ -23,7 +24,7 @@ const PREVIEW_CYCLE: Record<PreviewMode, PreviewMode> = {
/**
* Whether the active resource should show the in-progress file stream.
* The synthetic `streaming-file` tab always shows it; a real file tab only shows it
* when the streamed fileId matches that exact resource.
* after a preview content event has arrived for that exact resource.
*/
function shouldShowStreamingFilePanel(
previewSession: FilePreviewSession | null | undefined,
@@ -32,7 +33,9 @@ function shouldShowStreamingFilePanel(
if (!previewSession || previewSession.status === 'complete' || !active) return false
if (active.id === 'streaming-file') return true
if (active.type !== 'file') return false
if (active.id && previewSession.fileId === active.id) return true
if (active.id && previewSession.fileId === active.id) {
return hasRenderableFilePreviewContent(previewSession)
}
return false
}
@@ -104,8 +104,10 @@ import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/co
import {
buildCompletedPreviewSessions,
type FilePreviewSessionsState,
hasRenderableFilePreviewContent,
INITIAL_FILE_PREVIEW_SESSIONS_STATE,
reduceFilePreviewSessions,
shouldReplaceSession,
useFilePreviewSessions,
} from '@/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions'
import { deploymentKeys } from '@/hooks/queries/deployments'
@@ -1385,6 +1387,52 @@ export function useChat(
const activeResourceIdRef = useRef(effectiveActiveResourceId)
activeResourceIdRef.current = effectiveActiveResourceId
const previewActivationOwnerRef = useRef<Map<string, string | null>>(new Map())
const completedPreviewResourceHandoffRef = useRef<
Map<string, { sessionId: string; suppressActivation: boolean }>
>(new Map())
const rememberPreviewActivationOwner = useCallback((session: FilePreviewSession) => {
if (!session.fileId || previewActivationOwnerRef.current.has(session.id)) {
return
}
previewActivationOwnerRef.current.set(session.id, activeResourceIdRef.current)
}, [])
const shouldAutoActivatePreviewSession = useCallback((session: FilePreviewSession) => {
if (!session.fileId) {
return false
}
const currentActiveResourceId = activeResourceIdRef.current
const activationOwnerId = previewActivationOwnerRef.current.get(session.id)
return (
currentActiveResourceId === null ||
currentActiveResourceId === session.fileId ||
currentActiveResourceId === 'streaming-file' ||
currentActiveResourceId === activationOwnerId
)
}, [])
const seedCompletedPreviewContentCache = useCallback(
(fileId: string, previewText: string) => {
queryClient.setQueriesData<string>(
{ queryKey: workspaceFilesKeys.content(workspaceId, fileId, 'text') },
previewText
)
const activeFiles = queryClient.getQueryData<Array<{ id: string; key: string }>>(
workspaceFilesKeys.list(workspaceId, 'active')
)
const fileKey = activeFiles?.find((file) => file.id === fileId)?.key
if (fileKey) {
queryClient.setQueryData(
[...workspaceFilesKeys.content(workspaceId, fileId, 'text'), fileKey],
previewText
)
}
},
[queryClient, workspaceId]
)
const upsertTaskChatHistory = useCallback(
(chatId: string, updater: (current: TaskChatHistory) => TaskChatHistory) => {
@@ -1541,6 +1589,8 @@ export function useChat(
const resetEphemeralPreviewState = useCallback(
(options?: { removeStreamingResource?: boolean }) => {
previewActivationOwnerRef.current.clear()
completedPreviewResourceHandoffRef.current.clear()
syncPreviewSessionRefs(INITIAL_FILE_PREVIEW_SESSIONS_STATE)
resetPreviewSessions()
if (options?.removeStreamingResource) {
@@ -1550,35 +1600,40 @@ export function useChat(
[resetPreviewSessions, syncPreviewSessionRefs]
)
const syncPreviewResourceChrome = useCallback((session: FilePreviewSession) => {
if (session.targetKind === 'new_file') {
setResources((current) => {
const existing = current.find((resource) => resource.id === 'streaming-file')
if (existing) {
return current.map((resource) =>
resource.id === 'streaming-file'
? { ...resource, title: session.fileName || 'Writing file...' }
: resource
)
}
return [
...current,
{
type: 'file',
id: 'streaming-file',
title: session.fileName || 'Writing file...',
},
]
})
setActiveResourceId('streaming-file')
return
}
const syncPreviewResourceChrome = useCallback(
(session: FilePreviewSession, options?: { activate?: boolean }) => {
if (session.targetKind === 'new_file') {
setResources((current) => {
const existing = current.find((resource) => resource.id === 'streaming-file')
if (existing) {
return current.map((resource) =>
resource.id === 'streaming-file'
? { ...resource, title: session.fileName || 'Writing file...' }
: resource
)
}
return [
...current,
{
type: 'file',
id: 'streaming-file',
title: session.fileName || 'Writing file...',
},
]
})
setActiveResourceId('streaming-file')
return
}
if (session.fileId) {
setResources((current) => current.filter((resource) => resource.id !== 'streaming-file'))
setActiveResourceId(session.fileId)
}
}, [])
if (session.fileId && hasRenderableFilePreviewContent(session)) {
setResources((current) => current.filter((resource) => resource.id !== 'streaming-file'))
if (options?.activate !== false) {
setActiveResourceId(session.fileId)
}
}
},
[]
)
const seedPreviewSessions = useCallback(
(sessions: FilePreviewSession[]) => {
@@ -1597,10 +1652,17 @@ export function useChat(
? (nextState.sessions[nextState.activeSessionId] ?? null)
: null
if (active) {
syncPreviewResourceChrome(active)
syncPreviewResourceChrome(active, {
activate: active.targetKind === 'new_file' || shouldAutoActivatePreviewSession(active),
})
}
},
[hydratePreviewSessions, syncPreviewResourceChrome, syncPreviewSessionRefs]
[
hydratePreviewSessions,
shouldAutoActivatePreviewSession,
syncPreviewResourceChrome,
syncPreviewSessionRefs,
]
)
const abortControllerRef = useRef<AbortController | null>(null)
@@ -1965,7 +2027,8 @@ export function useChat(
void recoverPendingClientWorkflowTools(mappedMessages)
if (chatHistory.resources.some((r) => r.id === 'streaming-file')) {
const hasPersistedStreamingFile = chatHistory.resources.some((r) => r.id === 'streaming-file')
if (hasPersistedStreamingFile) {
// boundary-raw-fetch: fire-and-forget cleanup during chat-history hydration; failures are silently swallowed to keep hydration non-blocking
fetch('/api/mothership/chat/resources', {
method: 'DELETE',
@@ -1980,18 +2043,21 @@ export function useChat(
const persistedResources = chatHistory.resources.filter((r) => r.id !== 'streaming-file')
if (persistedResources.length > 0) {
setResources(persistedResources)
setActiveResourceId((prev) =>
prev && persistedResources.some((r) => r.id === prev)
? prev
const hydratedActiveResourceId =
activeResourceIdRef.current &&
persistedResources.some((resource) => resource.id === activeResourceIdRef.current)
? activeResourceIdRef.current
: persistedResources[persistedResources.length - 1].id
)
activeResourceIdRef.current = hydratedActiveResourceId
setResources(persistedResources)
setActiveResourceId(hydratedActiveResourceId)
for (const resource of persistedResources) {
if (resource.type !== 'workflow') continue
ensureWorkflowInRegistry(resource.id, resource.title, workspaceId)
}
} else if (chatHistory.resources.some((r) => r.id === 'streaming-file')) {
} else if (hasPersistedStreamingFile) {
activeResourceIdRef.current = null
setResources([])
setActiveResourceId(null)
}
@@ -2558,9 +2624,6 @@ export function useChat(
status: 'pending',
updatedAt: new Date().toISOString(),
}
if (nextSession.fileId) {
setActiveResourceId(nextSession.fileId)
}
applyPreviewSessionUpdate(nextSession)
break
}
@@ -2570,13 +2633,18 @@ export function useChat(
...baseSession,
updatedAt: new Date().toISOString(),
}
rememberPreviewActivationOwner(nextSession)
const nextState = applyPreviewSessionUpdate(nextSession)
const activePreview =
nextState.activeSessionId !== null
? (nextState.sessions[nextState.activeSessionId] ?? null)
: null
if (activePreview?.id === nextSession.id) {
syncPreviewResourceChrome(activePreview)
syncPreviewResourceChrome(activePreview, {
activate:
activePreview.targetKind === 'new_file' ||
shouldAutoActivatePreviewSession(activePreview),
})
}
break
}
@@ -2604,6 +2672,13 @@ export function useChat(
updatedAt: new Date().toISOString(),
}
applyPreviewSessionUpdate(nextSession)
if (!prevSession || !hasRenderableFilePreviewContent(prevSession)) {
syncPreviewResourceChrome(nextSession, {
activate:
nextSession.targetKind === 'new_file' ||
shouldAutoActivatePreviewSession(nextSession),
})
}
const previewToolIdx = toolMap.get(id)
if (previewToolIdx !== undefined && blocks[previewToolIdx].toolCall) {
blocks[previewToolIdx].toolCall!.status = 'executing'
@@ -2613,7 +2688,10 @@ export function useChat(
if (payload.previewPhase === 'file_preview_complete') {
const resultData = asPayloadRecord(payload.output)
const outputData = asPayloadRecord(resultData?.data) ?? resultData
const completedAt = new Date().toISOString()
const wasRenderableBeforeComplete =
prevSession !== undefined && hasRenderableFilePreviewContent(prevSession)
const nextSession: FilePreviewSession = {
...baseSession,
status: 'complete',
@@ -2623,8 +2701,8 @@ export function useChat(
}
const nextState = applyCompletedPreviewSession(nextSession)
if (fileId && resultData?.id) {
const fileName = (resultData.name as string) ?? nextSession.fileName ?? 'File'
if (fileId && resultData?.success === true && outputData?.id === fileId) {
const fileName = (outputData.name as string) ?? nextSession.fileName ?? 'File'
const fileResource = { type: 'file' as const, id: fileId, title: fileName }
setResources((rs) => {
const without = rs.filter((r) => r.id !== 'streaming-file')
@@ -2633,12 +2711,19 @@ export function useChat(
}
return [...without, fileResource]
})
setActiveResourceId(fileId)
if (nextSession.previewText) {
queryClient.setQueryData(
workspaceFilesKeys.content(workspaceId, fileId, 'text'),
nextSession.previewText
)
const shouldActivateOnComplete =
!wasRenderableBeforeComplete &&
hasRenderableFilePreviewContent(nextSession) &&
shouldAutoActivatePreviewSession(nextSession)
if (shouldActivateOnComplete) {
setActiveResourceId(fileId)
}
completedPreviewResourceHandoffRef.current.set(fileId, {
sessionId: nextSession.id,
suppressActivation: !shouldActivateOnComplete,
})
if (hasRenderableFilePreviewContent(nextSession)) {
seedCompletedPreviewContentCache(fileId, nextSession.previewText)
}
invalidateResourceQueries(queryClient, workspaceId, 'file', fileId)
} else {
@@ -2647,7 +2732,11 @@ export function useChat(
? (nextState.sessions[nextState.activeSessionId] ?? null)
: null
if (activePreview) {
syncPreviewResourceChrome(activePreview)
syncPreviewResourceChrome(activePreview, {
activate:
activePreview.targetKind === 'new_file' ||
shouldAutoActivatePreviewSession(activePreview),
})
}
}
break
@@ -2893,7 +2982,59 @@ export function useChat(
id: resource.id,
title: typeof resource.title === 'string' ? resource.title : resource.id,
}
const wasAdded = addResource(nextResource)
const completedPreviewHandoff =
nextResource.type === 'file'
? completedPreviewResourceHandoffRef.current.get(nextResource.id)
: undefined
const matchingPreviewSessions =
nextResource.type === 'file'
? Object.values(previewSessionsRef.current).filter(
(session) => session.fileId === nextResource.id
)
: []
const latestPreviewForResource = (
sessions: FilePreviewSession[]
): FilePreviewSession | undefined =>
sessions.reduce<FilePreviewSession | undefined>(
(latest, session) => (shouldReplaceSession(latest, session) ? session : latest),
undefined
)
const latestActivePreviewForResource = latestPreviewForResource(
matchingPreviewSessions.filter((session) => session.status !== 'complete')
)
const previewForResource =
latestActivePreviewForResource ?? latestPreviewForResource(matchingPreviewSessions)
const isCompletedPreviewHandoffCurrent =
completedPreviewHandoff !== undefined &&
(!latestActivePreviewForResource ||
latestActivePreviewForResource.id === completedPreviewHandoff.sessionId)
if (completedPreviewHandoff && !isCompletedPreviewHandoffCurrent) {
completedPreviewResourceHandoffRef.current.delete(nextResource.id)
previewActivationOwnerRef.current.delete(completedPreviewHandoff.sessionId)
}
const shouldSuppressFileResourceActivation =
(isCompletedPreviewHandoffCurrent &&
completedPreviewHandoff?.suppressActivation === true) ||
(previewForResource !== undefined &&
previewForResource.status !== 'complete' &&
(!hasRenderableFilePreviewContent(previewForResource) ||
!shouldAutoActivatePreviewSession(previewForResource)))
const wasAdded = shouldSuppressFileResourceActivation
? !resourcesRef.current.some(
(r) => r.type === nextResource.type && r.id === nextResource.id
)
: addResource(nextResource)
if (shouldSuppressFileResourceActivation && wasAdded) {
setResources((current) =>
current.some((r) => r.type === nextResource.type && r.id === nextResource.id)
? current
: [...current, nextResource]
)
}
if (completedPreviewHandoff && isCompletedPreviewHandoffCurrent) {
completedPreviewResourceHandoffRef.current.delete(nextResource.id)
previewActivationOwnerRef.current.delete(completedPreviewHandoff.sessionId)
}
invalidateResourceQueries(
queryClient,
workspaceId,
@@ -2901,7 +3042,11 @@ export function useChat(
nextResource.id
)
if (!wasAdded && activeResourceIdRef.current !== nextResource.id) {
if (
!shouldSuppressFileResourceActivation &&
!wasAdded &&
activeResourceIdRef.current !== nextResource.id
) {
setActiveResourceId(nextResource.id)
}
onResourceEventRef.current?.()
@@ -5,8 +5,10 @@ import { describe, expect, it } from 'vitest'
import type { FilePreviewSession } from '@/lib/copilot/request/session'
import {
buildCompletedPreviewSessions,
hasRenderableFilePreviewContent,
INITIAL_FILE_PREVIEW_SESSIONS_STATE,
reduceFilePreviewSessions,
shouldReplaceSession,
} from '@/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions'
function createSession(
@@ -31,6 +33,56 @@ function createSession(
}
describe('reduceFilePreviewSessions', () => {
it('does not treat a pending empty preview as renderable content', () => {
expect(
hasRenderableFilePreviewContent(
createSession({
id: 'preview-1',
toolCallId: 'preview-1',
status: 'pending',
previewText: '',
previewVersion: 0,
})
)
).toBe(false)
})
it('treats emitted preview snapshots as renderable even when empty', () => {
expect(
hasRenderableFilePreviewContent(
createSession({
id: 'preview-1',
toolCallId: 'preview-1',
status: 'streaming',
previewText: '',
previewVersion: 1,
})
)
).toBe(true)
})
it('does not replace a completed session with same-version replayed streaming events', () => {
const completed = createSession({
id: 'preview-1',
toolCallId: 'preview-1',
status: 'complete',
previewText: 'final',
previewVersion: 2,
updatedAt: '2026-04-10T00:00:02.000Z',
completedAt: '2026-04-10T00:00:02.000Z',
})
const replayedStreaming = createSession({
id: 'preview-1',
toolCallId: 'preview-1',
status: 'streaming',
previewText: 'final',
previewVersion: 2,
updatedAt: '2026-04-10T00:00:03.000Z',
})
expect(shouldReplaceSession(completed, replayedStreaming)).toBe(false)
})
it('builds complete sessions for terminal stream reconciliation', () => {
const completedAt = '2026-04-10T00:00:10.000Z'
const nextSessions = buildCompletedPreviewSessions(
@@ -18,11 +18,22 @@ export const INITIAL_FILE_PREVIEW_SESSIONS_STATE: FilePreviewSessionsState = {
sessions: {},
}
export function hasRenderableFilePreviewContent(session: FilePreviewSession): boolean {
return session.previewText.length > 0 || session.previewVersion > 0
}
export function shouldReplaceSession(
current: FilePreviewSession | undefined,
next: FilePreviewSession
): boolean {
if (!current) return true
if (
current.status === 'complete' &&
next.status !== 'complete' &&
next.previewVersion <= current.previewVersion
) {
return false
}
if (next.previewVersion !== current.previewVersion) {
return next.previewVersion > current.previewVersion
}