Skip to content

Commit d20a8a8

Browse files
committed
File streaming
1 parent ecb63d9 commit d20a8a8

11 files changed

Lines changed: 215 additions & 20 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ interface FileViewerProps {
7777
onDirtyChange?: (isDirty: boolean) => void
7878
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
7979
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
80+
streamingContent?: string
8081
}
8182

8283
export function FileViewer({
@@ -89,6 +90,7 @@ export function FileViewer({
8990
onDirtyChange,
9091
onSaveStatusChange,
9192
saveRef,
93+
streamingContent,
9294
}: FileViewerProps) {
9395
const category = resolveFileCategory(file.type, file.name)
9496

@@ -97,12 +99,13 @@ export function FileViewer({
9799
<TextEditor
98100
file={file}
99101
workspaceId={workspaceId}
100-
canEdit={canEdit}
102+
canEdit={streamingContent !== undefined ? false : canEdit}
101103
previewMode={previewMode ?? (showPreview ? 'preview' : 'editor')}
102104
autoFocus={autoFocus}
103105
onDirtyChange={onDirtyChange}
104106
onSaveStatusChange={onSaveStatusChange}
105107
saveRef={saveRef}
108+
streamingContent={streamingContent}
106109
/>
107110
)
108111
}
@@ -123,6 +126,7 @@ interface TextEditorProps {
123126
onDirtyChange?: (isDirty: boolean) => void
124127
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
125128
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
129+
streamingContent?: string
126130
}
127131

128132
function TextEditor({
@@ -134,6 +138,7 @@ function TextEditor({
134138
onDirtyChange,
135139
onSaveStatusChange,
136140
saveRef,
141+
streamingContent,
137142
}: TextEditorProps) {
138143
const initializedRef = useRef(false)
139144
const contentRef = useRef('')
@@ -157,6 +162,13 @@ function TextEditor({
157162
const savedContentRef = useRef('')
158163

159164
useEffect(() => {
165+
if (streamingContent !== undefined) {
166+
setContent(streamingContent)
167+
contentRef.current = streamingContent
168+
initializedRef.current = true
169+
return
170+
}
171+
160172
if (fetchedContent === undefined) return
161173

162174
if (!initializedRef.current) {
@@ -180,7 +192,7 @@ function TextEditor({
180192
savedContentRef.current = fetchedContent
181193
contentRef.current = fetchedContent
182194
}
183-
}, [fetchedContent, dataUpdatedAt, autoFocus])
195+
}, [streamingContent, fetchedContent, dataUpdatedAt, autoFocus])
184196

185197
const handleContentChange = useCallback((value: string) => {
186198
setContent(value)
@@ -252,23 +264,25 @@ function TextEditor({
252264
}
253265
}, [isResizing])
254266

255-
if (isLoading) {
256-
return (
257-
<div className='flex flex-1 flex-col gap-[8px] p-[24px]'>
258-
<Skeleton className='h-[16px] w-[60%]' />
259-
<Skeleton className='h-[16px] w-[80%]' />
260-
<Skeleton className='h-[16px] w-[40%]' />
261-
<Skeleton className='h-[16px] w-[70%]' />
262-
</div>
263-
)
264-
}
267+
if (streamingContent === undefined) {
268+
if (isLoading) {
269+
return (
270+
<div className='flex flex-1 flex-col gap-[8px] p-[24px]'>
271+
<Skeleton className='h-[16px] w-[60%]' />
272+
<Skeleton className='h-[16px] w-[80%]' />
273+
<Skeleton className='h-[16px] w-[40%]' />
274+
<Skeleton className='h-[16px] w-[70%]' />
275+
</div>
276+
)
277+
}
265278

266-
if (error) {
267-
return (
268-
<div className='flex flex-1 items-center justify-center'>
269-
<p className='text-[13px] text-[var(--text-muted)]'>Failed to load file content</p>
270-
</div>
271-
)
279+
if (error) {
280+
return (
281+
<div className='flex flex-1 items-center justify-center'>
282+
<p className='text-[13px] text-[var(--text-muted)]'>Failed to load file content</p>
283+
</div>
284+
)
285+
}
272286
}
273287

274288
const showEditor = previewMode !== 'preview'

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export function AgentGroup({
9999
displayTitle={item.data.displayTitle}
100100
status={item.data.status}
101101
result={item.data.result}
102+
streamingArgs={item.data.streamingArgs}
102103
/>
103104
) : (
104105
<span

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ function toToolData(tc: NonNullable<ContentBlock['toolCall']>): ToolCallData {
5353
formatToolName(tc.name),
5454
status: tc.status,
5555
result: tc.result,
56+
streamingArgs: tc.streamingArgs,
5657
}
5758
}
5859

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,56 @@ interface ResourceContentProps {
4444
workspaceId: string
4545
resource: MothershipResource
4646
previewMode?: PreviewMode
47+
streamingFile?: { fileName: string; content: string } | null
4748
}
4849

4950
/**
5051
* Renders the content for the currently active mothership resource.
5152
* Handles table, file, and workflow resource types with appropriate
5253
* embedded rendering for each.
5354
*/
55+
const STREAMING_EPOCH = new Date(0)
56+
5457
export const ResourceContent = memo(function ResourceContent({
5558
workspaceId,
5659
resource,
5760
previewMode,
61+
streamingFile,
5862
}: ResourceContentProps) {
63+
const streamFileName = streamingFile?.fileName || 'file.md'
64+
const streamingExtractedContent = useMemo(
65+
() => (streamingFile ? extractFileContent(streamingFile.content) : ''),
66+
[streamingFile]
67+
)
68+
const syntheticFile = useMemo(
69+
() => ({
70+
id: 'streaming-file',
71+
workspaceId,
72+
name: streamFileName,
73+
key: '',
74+
path: '',
75+
size: 0,
76+
type: 'text/plain',
77+
uploadedBy: '',
78+
uploadedAt: STREAMING_EPOCH,
79+
}),
80+
[workspaceId, streamFileName]
81+
)
82+
83+
if (streamingFile) {
84+
return (
85+
<div className='flex h-full flex-col overflow-hidden'>
86+
<FileViewer
87+
file={syntheticFile}
88+
workspaceId={workspaceId}
89+
canEdit={false}
90+
previewMode={previewMode ?? 'preview'}
91+
streamingContent={streamingExtractedContent}
92+
/>
93+
</div>
94+
)
95+
}
96+
5997
switch (resource.type) {
6098
case 'table':
6199
return <Table key={resource.id} workspaceId={workspaceId} tableId={resource.id} embedded />
@@ -375,3 +413,17 @@ function EmbeddedFile({ workspaceId, fileId, previewMode }: EmbeddedFileProps) {
375413
</div>
376414
)
377415
}
416+
417+
function extractFileContent(raw: string): string {
418+
const marker = '"content":'
419+
const idx = raw.indexOf(marker)
420+
if (idx === -1) return ''
421+
let rest = raw.slice(idx + marker.length).trimStart()
422+
if (rest.startsWith('"')) rest = rest.slice(1)
423+
return rest
424+
.replace(/\\n/g, '\n')
425+
.replace(/\\t/g, '\t')
426+
.replace(/\\"/g, '"')
427+
.replace(/\\\\/g, '\\')
428+
}
429+

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ interface MothershipViewProps {
2929
onCollapse: () => void
3030
isCollapsed: boolean
3131
className?: string
32+
streamingFile?: { fileName: string; content: string } | null
3233
}
3334

3435
export const MothershipView = memo(function MothershipView({
@@ -43,6 +44,7 @@ export const MothershipView = memo(function MothershipView({
4344
onCollapse,
4445
isCollapsed,
4546
className,
47+
streamingFile,
4648
}: MothershipViewProps) {
4749
const active = resources.find((r) => r.id === activeResourceId) ?? resources[0] ?? null
4850

@@ -85,6 +87,7 @@ export const MothershipView = memo(function MothershipView({
8587
workspaceId={workspaceId}
8688
resource={active}
8789
previewMode={isActivePreviewable ? previewMode : undefined}
90+
streamingFile={active.id === 'streaming-file' ? streamingFile : undefined}
8891
/>
8992
) : (
9093
<div className='flex h-full items-center justify-center text-[14px] text-[var(--text-muted)]'>

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ export function Home({ chatId }: HomeProps = {}) {
175175
removeFromQueue,
176176
sendNow,
177177
editQueuedMessage,
178+
streamingFile,
178179
} = useChat(workspaceId, chatId, { onResourceEvent: handleResourceEvent })
179180

180181
const [editingInputValue, setEditingInputValue] = useState('')
@@ -469,6 +470,7 @@ export function Home({ chatId }: HomeProps = {}) {
469470
onReorderResources={reorderResources}
470471
onCollapse={collapseResource}
471472
isCollapsed={isResourceCollapsed}
473+
streamingFile={streamingFile}
472474
className={
473475
isResourceAnimatingIn
474476
? 'animate-slide-in-right'

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export interface UseChatReturn {
7070
removeFromQueue: (id: string) => void
7171
sendNow: (id: string) => Promise<void>
7272
editQueuedMessage: (id: string) => QueuedMessage | undefined
73+
streamingFile: { fileName: string; content: string } | null
7374
}
7475

7576
const STATE_TO_STATUS: Record<string, ToolCallStatus> = {
@@ -290,6 +291,13 @@ export function useChat(
290291
const activeResourceIdRef = useRef(activeResourceId)
291292
activeResourceIdRef.current = activeResourceId
292293

294+
const [streamingFile, setStreamingFile] = useState<{
295+
fileName: string
296+
content: string
297+
} | null>(null)
298+
const streamingFileRef = useRef(streamingFile)
299+
streamingFileRef.current = streamingFile
300+
293301
const [messageQueue, setMessageQueue] = useState<QueuedMessage[]>([])
294302
const messageQueueRef = useRef<QueuedMessage[]>([])
295303
useEffect(() => {
@@ -728,6 +736,43 @@ export function useChat(
728736
}
729737
break
730738
}
739+
case 'tool_call_delta': {
740+
const id = parsed.toolCallId
741+
const delta = typeof parsed.data === 'string' ? parsed.data : ''
742+
if (!id || !delta) break
743+
744+
if (activeSubagent === 'file_write') {
745+
const prev = streamingFileRef.current
746+
if (prev) {
747+
const raw = prev.content + delta
748+
let fileName = prev.fileName
749+
if (!fileName) {
750+
const m = raw.match(/"fileName"\s*:\s*"([^"]+)"/)
751+
if (m) {
752+
fileName = m[1]
753+
setResources((rs) =>
754+
rs.map((r) =>
755+
r.id === 'streaming-file'
756+
? { ...r, title: fileName }
757+
: r
758+
)
759+
)
760+
}
761+
}
762+
const next = { fileName, content: raw }
763+
streamingFileRef.current = next
764+
setStreamingFile(next)
765+
}
766+
}
767+
768+
const idx = toolMap.get(id)
769+
if (idx !== undefined && blocks[idx].toolCall) {
770+
const tc = blocks[idx].toolCall!
771+
tc.streamingArgs = (tc.streamingArgs ?? '') + delta
772+
flush()
773+
}
774+
break
775+
}
731776
case 'tool_result': {
732777
const id = parsed.toolCallId || getPayloadData(parsed)?.id
733778
if (!id) break
@@ -753,6 +798,7 @@ export function useChat(
753798
} else {
754799
tc.status = parsed.success ? 'success' : 'error'
755800
}
801+
tc.streamingArgs = undefined
756802
tc.result = {
757803
success: !!parsed.success,
758804
output: parsed.result ?? getPayloadData(parsed)?.result,
@@ -907,11 +953,20 @@ export function useChat(
907953
if (name) {
908954
activeSubagent = name
909955
blocks.push({ type: 'subagent', content: name })
956+
if (name === 'file_write') {
957+
setStreamingFile({ fileName: '', content: '' })
958+
addResource({ type: 'file', id: 'streaming-file', title: 'Writing file...' })
959+
}
910960
flush()
911961
}
912962
break
913963
}
914964
case 'subagent_end': {
965+
if (activeSubagent === 'file_write') {
966+
setStreamingFile(null)
967+
streamingFileRef.current = null
968+
setResources((rs) => rs.filter((r) => r.id !== 'streaming-file'))
969+
}
915970
activeSubagent = undefined
916971
blocks.push({ type: 'subagent_end' })
917972
flush()
@@ -1323,5 +1378,6 @@ export function useChat(
13231378
removeFromQueue,
13241379
sendNow,
13251380
editQueuedMessage,
1381+
streamingFile,
13261382
}
13271383
}

apps/sim/app/workspace/[workspaceId]/home/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export interface ToolCallData {
145145
displayTitle: string
146146
status: ToolCallStatus
147147
result?: ToolCallResult
148+
streamingArgs?: string
148149
}
149150

150151
export interface ToolCallInfo {
@@ -155,6 +156,7 @@ export interface ToolCallInfo {
155156
phaseLabel?: string
156157
calledBy?: string
157158
result?: { success: boolean; output?: unknown; error?: string }
159+
streamingArgs?: string
158160
}
159161

160162
export interface OptionItem {

apps/sim/lib/copilot/client-sse/handlers.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -790,8 +790,24 @@ export const sseHandlers: Record<string, SSEHandler> = {
790790
})
791791
}
792792
},
793-
tool_call_delta: () => {
794-
// Argument streaming delta — forwarded from Go, no client action yet
793+
tool_call_delta: (data, context, get, set) => {
794+
const toolCallId = data?.toolCallId
795+
if (!toolCallId) return
796+
797+
const delta = typeof data?.data === 'string' ? data.data : ''
798+
if (!delta) return
799+
800+
const { toolCallsById } = get()
801+
const existing = toolCallsById[toolCallId]
802+
if (!existing) return
803+
804+
const updated: CopilotToolCall = {
805+
...existing,
806+
streamingArgs: (existing.streamingArgs ?? '') + delta,
807+
}
808+
set({ toolCallsById: { ...toolCallsById, [toolCallId]: updated } })
809+
upsertToolCallBlock(context, updated)
810+
updateStreamingMessage(set, context)
795811
},
796812
tool_generating: (data, context, get, set) => {
797813
const { toolCallId, toolName } = data
@@ -860,6 +876,7 @@ export const sseHandlers: Record<string, SSEHandler> = {
860876
...(args ? { params: args } : {}),
861877
...(effectiveServerUI ? { serverUI: effectiveServerUI } : {}),
862878
...(clientExecutable ? { clientExecutable: true } : {}),
879+
...(!isPartial ? { streamingArgs: undefined } : {}),
863880
display: resolveToolDisplay(
864881
toolName,
865882
initialState,

0 commit comments

Comments
 (0)