This commit is contained in:
Siddharth Ganesan
2026-04-08 11:38:06 -07:00
parent 89f88426e2
commit 3893afd424
10 changed files with 544 additions and 676 deletions

View File

@@ -130,6 +130,7 @@ interface FileViewerProps {
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
streamingContent?: string
streamingMode?: 'append' | 'replace'
}
export function FileViewer({
@@ -143,6 +144,7 @@ export function FileViewer({
onSaveStatusChange,
saveRef,
streamingContent,
streamingMode,
}: FileViewerProps) {
const category = resolveFileCategory(file.type, file.name)
@@ -158,6 +160,7 @@ export function FileViewer({
onSaveStatusChange={onSaveStatusChange}
saveRef={saveRef}
streamingContent={streamingContent}
streamingMode={streamingMode}
/>
)
}
@@ -195,6 +198,7 @@ interface TextEditorProps {
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
streamingContent?: string
streamingMode?: 'append' | 'replace'
}
function TextEditor({
@@ -207,6 +211,7 @@ function TextEditor({
onSaveStatusChange,
saveRef,
streamingContent,
streamingMode = 'append',
}: TextEditorProps) {
const initializedRef = useRef(false)
const contentRef = useRef('')
@@ -237,15 +242,13 @@ function TextEditor({
const [content, setContent] = useState('')
const [savedContent, setSavedContent] = useState('')
const savedContentRef = useRef('')
const wasStreamingRef = useRef(false)
useEffect(() => {
if (streamingContent !== undefined) {
const isSplicedFull =
fetchedContent !== undefined &&
streamingContent.length > fetchedContent.length * 0.5 &&
streamingContent.startsWith(fetchedContent.slice(0, Math.min(100, fetchedContent.length)))
wasStreamingRef.current = true
const nextContent =
fetchedContent === undefined || isSplicedFull
streamingMode === 'replace' || fetchedContent === undefined
? streamingContent
: fetchedContent.endsWith(streamingContent) ||
fetchedContent.endsWith(`\n${streamingContent}`)
@@ -257,6 +260,17 @@ function TextEditor({
return
}
if (wasStreamingRef.current) {
wasStreamingRef.current = false
if (fetchedContent !== undefined) {
setContent(fetchedContent)
setSavedContent(fetchedContent)
savedContentRef.current = fetchedContent
contentRef.current = fetchedContent
return
}
}
if (fetchedContent === undefined) return
if (!initializedRef.current) {

View File

@@ -1,7 +1,7 @@
'use client'
import {
FileWrite,
File as FileTool,
Read as ReadTool,
ToolSearchToolRegex,
WorkspaceFile,
@@ -43,12 +43,12 @@ const SUBAGENT_KEYS = new Set(Object.keys(SUBAGENT_LABELS))
/**
* Maps subagent names to the Mothership tool that dispatches them when the
* tool name differs from the subagent name (e.g. `workspace_file` → `file_write`).
* tool name differs from the subagent name (e.g. `workspace_file` → `file`).
* When a `subagent` block arrives, any trailing dispatch tool in the previous
* group is absorbed so it doesn't render as a separate Mothership entry.
*/
const SUBAGENT_DISPATCH_TOOLS: Record<string, string> = {
[FileWrite.id]: WorkspaceFile.id,
[FileTool.id]: WorkspaceFile.id,
}
function isToolResultRead(params?: Record<string, unknown>): boolean {

View File

@@ -57,7 +57,7 @@ const TOOL_ICONS: Record<string, IconComponent> = {
debug: Bug,
context_compaction: Asterisk,
open_resource: Eye,
file_write: File,
file: File,
}
export function getAgentIcon(name: string): IconComponent {

View File

@@ -85,11 +85,16 @@ export const ResourceContent = memo(function ResourceContent({
}: ResourceContentProps) {
const streamFileName = streamingFile?.fileName || 'file.md'
const isPatchStream = useMemo(() => {
if (!streamingFile) return false
return /"operation"\s*:\s*"patch"/.test(streamingFile.content)
const streamOperation = useMemo(() => {
if (!streamingFile) return undefined
const m = streamingFile.content.match(/"operation"\s*:\s*"(\w+)"/)
return m?.[1]
}, [streamingFile])
const isWriteStream = streamOperation === 'write'
const isPatchStream = streamOperation === 'patch'
const isUpdateStream = streamOperation === 'update'
const { data: allFiles = [] } = useWorkspaceFiles(workspaceId)
const activeFileRecord = useMemo(() => {
if (!isPatchStream || resource.type !== 'file') return undefined
@@ -112,13 +117,25 @@ export const ResourceContent = memo(function ResourceContent({
if (!streamingFile) return undefined
const raw = streamingFile.content
if (isPatchStream && fetchedFileContent) {
// Do not guess. Until the operation key has streamed in, we don't know
// whether the payload should append, replace, or splice into the file.
// Rendering early here can show content at the end of the file and then
// "snap" to the right place once the operation/mode becomes known.
if (!streamOperation) return undefined
if (isPatchStream) {
if (!fetchedFileContent) return undefined
return extractPatchPreview(raw, fetchedFileContent)
}
const extracted = extractFileContent(raw)
return extracted.length > 0 ? extracted : undefined
}, [streamingFile, isPatchStream, fetchedFileContent])
if (extracted.length === 0) return undefined
if (isUpdateStream) return extracted
if (isWriteStream) return extracted
return undefined
}, [streamingFile, streamOperation, isWriteStream, isPatchStream, isUpdateStream, fetchedFileContent])
const syntheticFile = useMemo(() => {
const ext = getFileExtension(streamFileName)
const SOURCE_MIME_MAP: Record<string, string> = {
@@ -140,6 +157,9 @@ export const ResourceContent = memo(function ResourceContent({
}
}, [workspaceId, streamFileName])
const streamingFileMode: 'append' | 'replace' =
isWriteStream ? 'append' : 'replace'
if (streamingFile && resource.id === 'streaming-file') {
return (
<div className='flex h-full flex-col overflow-hidden'>
@@ -150,6 +170,7 @@ export const ResourceContent = memo(function ResourceContent({
canEdit={false}
previewMode={previewMode ?? 'preview'}
streamingContent={streamingExtractedContent}
streamingMode={streamingFileMode}
/>
) : (
<div className='flex h-full items-center justify-center'>
@@ -172,6 +193,7 @@ export const ResourceContent = memo(function ResourceContent({
fileId={resource.id}
previewMode={previewMode}
streamingContent={streamingExtractedContent}
streamingMode={streamingFileMode}
/>
)
@@ -460,9 +482,10 @@ interface EmbeddedFileProps {
fileId: string
previewMode?: PreviewMode
streamingContent?: string
streamingMode?: 'append' | 'replace'
}
function EmbeddedFile({ workspaceId, fileId, previewMode, streamingContent }: EmbeddedFileProps) {
function EmbeddedFile({ workspaceId, fileId, previewMode, streamingContent, streamingMode }: EmbeddedFileProps) {
const { canEdit } = useUserPermissionsContext()
const { data: files = [], isLoading, isFetching } = useWorkspaceFiles(workspaceId)
const file = useMemo(() => files.find((f) => f.id === fileId), [files, fileId])
@@ -490,6 +513,7 @@ function EmbeddedFile({ workspaceId, fileId, previewMode, streamingContent }: Em
file={file}
workspaceId={workspaceId}
canEdit={canEdit}
streamingMode={streamingMode}
previewMode={previewMode}
streamingContent={streamingContent}
/>

View File

@@ -30,7 +30,7 @@ function fileTitlesEquivalent(streamFileName: string, resourceTitle: string): bo
}
/**
* Whether the active resource should show the in-progress file_write stream.
* Whether the active resource should show the in-progress file stream.
* The synthetic `streaming-file` tab always shows it; a real file tab shows it when
* the streamed `fileName` matches that resource (so users who stay on the open file see live text).
*/

View File

@@ -27,7 +27,7 @@ import {
DeployApi,
DeployChat,
DeployMcp,
FileWrite,
File as FileTool,
MoveFolder,
MoveWorkflow,
Read as ReadTool,
@@ -914,9 +914,16 @@ export function useChat(
)
if (existingFileMatch) {
setActiveResourceId(matchedResourceId)
setResources((rs) => rs.filter((resource) => resource.id !== 'streaming-file'))
} else if (fileName || fileIdMatch || activeSubagent === 'file_write') {
const hadStreamingResource = resourcesRef.current.some(
(resource) => resource.id === 'streaming-file'
)
if (hadStreamingResource) {
setResources((rs) => rs.filter((resource) => resource.id !== 'streaming-file'))
setActiveResourceId(matchedResourceId)
} else if (activeResourceIdRef.current === null) {
setActiveResourceId(matchedResourceId)
}
} else if (fileName || fileIdMatch || activeSubagent === FileTool.id) {
const hasStreamingResource = resourcesRef.current.some(
(resource) => resource.id === 'streaming-file'
)
@@ -927,8 +934,6 @@ export function useChat(
title: fileName || 'Writing file...',
})
setActiveResourceId('streaming-file')
} else if (activeResourceIdRef.current !== 'streaming-file') {
setActiveResourceId('streaming-file')
}
}
const next = { fileName, fileId: matchedResourceId, content: raw }
@@ -1294,7 +1299,7 @@ export function useChat(
if (!isSameActiveSubagent) {
blocks.push({ type: 'subagent', content: name })
}
if (name === FileWrite.id) {
if (name === FileTool.id) {
const emptyFile = { fileName: '', content: '' }
streamingFileRef.current = emptyFile
setStreamingFile(emptyFile)

View File

@@ -190,7 +190,7 @@ export const SUBAGENT_LABELS: Record<string, string> = {
run: 'Run agent',
agent: 'Agent manager',
job: 'Job agent',
file_write: 'File Write',
file: 'File',
} as const
export interface ToolUIMetadata {

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,7 @@ import type {
StreamingContext,
ToolCallState,
} from '@/lib/copilot/request/types'
import { isSimExecuted } from '@/lib/copilot/tool-executor'
import { isSimExecuted, getToolEntry } from '@/lib/copilot/tool-executor'
import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools'
import type { ToolScope } from './types'
import {
@@ -191,8 +191,10 @@ async function handleCallPhase(
if (isGoHandledInternalRead) return
const { clientExecutable, simExecutable, internal } = getEventUI(event)
const catalogEntry = getToolEntry(toolName)
const isInternal = internal || catalogEntry?.internal === true
const staticSimExecuted = isSimExecuted(toolName)
const willDispatch = !internal && (staticSimExecuted || simExecutable || clientExecutable)
const willDispatch = !isInternal && (staticSimExecuted || simExecutable || clientExecutable)
logger.info('Tool call routing decision', {
toolCallId,
toolName,
@@ -203,12 +205,12 @@ async function handleCallPhase(
clientExecutable,
simExecutable,
staticSimExecuted,
internal,
internal: isInternal,
hasPendingPromise: context.pendingToolPromises.has(toolCallId),
existingStatus: existing?.status,
willDispatch,
})
if (internal) return
if (isInternal) return
if (!willDispatch) return
await dispatchToolExecution(

View File

@@ -8,6 +8,7 @@ export {
} from './executor'
export { ensureHandlersRegistered } from './register-handlers'
export {
getToolEntry,
isGoExecuted,
isKnownTool,
isSimExecuted,