Skip to content

Commit 09f06cb

Browse files
committed
durable stream for files
1 parent ac84c62 commit 09f06cb

File tree

27 files changed

+2059
-523
lines changed

27 files changed

+2059
-523
lines changed

apps/sim/app/api/copilot/chat/queries.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@ import { copilotChats } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, desc, eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
6+
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
67
import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle'
78
import {
89
authenticateCopilotRequestSessionOnly,
910
createBadRequestResponse,
1011
createInternalServerErrorResponse,
1112
createUnauthorizedResponse,
1213
} from '@/lib/copilot/request/http'
14+
import { readFilePreviewSessions } from '@/lib/copilot/request/session'
15+
import { readEvents } from '@/lib/copilot/request/session/buffer'
16+
import { toStreamBatchEvent } from '@/lib/copilot/request/session/types'
1317
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
1418
import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
1519

@@ -63,8 +67,60 @@ export async function GET(req: NextRequest) {
6367
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
6468
}
6569

70+
let streamSnapshot: {
71+
events: ReturnType<typeof toStreamBatchEvent>[]
72+
previewSessions: Awaited<ReturnType<typeof readFilePreviewSessions>>
73+
status: string
74+
} | null = null
75+
if (chat.conversationId) {
76+
try {
77+
const [events, previewSessions, run] = await Promise.all([
78+
readEvents(chat.conversationId, '0'),
79+
readFilePreviewSessions(chat.conversationId).catch((error) => {
80+
logger.warn('Failed to read preview sessions for copilot chat', {
81+
chatId,
82+
conversationId: chat.conversationId,
83+
error: error instanceof Error ? error.message : String(error),
84+
})
85+
return []
86+
}),
87+
getLatestRunForStream(chat.conversationId, authenticatedUserId).catch((error) => {
88+
logger.warn('Failed to fetch latest run for copilot chat snapshot', {
89+
chatId,
90+
conversationId: chat.conversationId,
91+
error: error instanceof Error ? error.message : String(error),
92+
})
93+
return null
94+
}),
95+
])
96+
97+
streamSnapshot = {
98+
events: events.map(toStreamBatchEvent),
99+
previewSessions,
100+
status:
101+
typeof run?.status === 'string'
102+
? run.status
103+
: events.length > 0
104+
? 'active'
105+
: 'unknown',
106+
}
107+
} catch (error) {
108+
logger.warn('Failed to load copilot chat stream snapshot', {
109+
chatId,
110+
conversationId: chat.conversationId,
111+
error: error instanceof Error ? error.message : String(error),
112+
})
113+
}
114+
}
115+
66116
logger.info(`Retrieved chat ${chatId}`)
67-
return NextResponse.json({ success: true, chat: transformChat(chat) })
117+
return NextResponse.json({
118+
success: true,
119+
chat: {
120+
...transformChat(chat),
121+
...(streamSnapshot ? { streamSnapshot } : {}),
122+
},
123+
})
68124
}
69125

70126
if (!workflowId && !workspaceId) {

apps/sim/app/api/copilot/chat/stream/route.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ import {
1212
const {
1313
getLatestRunForStream,
1414
readEvents,
15+
readFilePreviewSessions,
1516
checkForReplayGap,
1617
authenticateCopilotRequestSessionOnly,
1718
} = vi.hoisted(() => ({
1819
getLatestRunForStream: vi.fn(),
1920
readEvents: vi.fn(),
21+
readFilePreviewSessions: vi.fn(),
2022
checkForReplayGap: vi.fn(),
2123
authenticateCopilotRequestSessionOnly: vi.fn(),
2224
}))
@@ -27,6 +29,7 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({
2729

2830
vi.mock('@/lib/copilot/request/session', () => ({
2931
readEvents,
32+
readFilePreviewSessions,
3033
checkForReplayGap,
3134
createEvent: (event: Record<string, unknown>) => ({
3235
stream: {
@@ -74,9 +77,50 @@ describe('copilot chat stream replay route', () => {
7477
isAuthenticated: true,
7578
})
7679
readEvents.mockResolvedValue([])
80+
readFilePreviewSessions.mockResolvedValue([])
7781
checkForReplayGap.mockResolvedValue(null)
7882
})
7983

84+
it('returns preview sessions in batch mode', async () => {
85+
getLatestRunForStream.mockResolvedValue({
86+
status: 'active',
87+
executionId: 'exec-1',
88+
id: 'run-1',
89+
})
90+
readFilePreviewSessions.mockResolvedValue([
91+
{
92+
schemaVersion: 1,
93+
id: 'preview-1',
94+
streamId: 'stream-1',
95+
toolCallId: 'preview-1',
96+
status: 'streaming',
97+
fileName: 'draft.md',
98+
previewText: 'hello',
99+
previewVersion: 2,
100+
updatedAt: '2026-04-10T00:00:00.000Z',
101+
},
102+
])
103+
104+
const response = await GET(
105+
new NextRequest(
106+
'http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&after=0&batch=true'
107+
)
108+
)
109+
110+
expect(response.status).toBe(200)
111+
await expect(response.json()).resolves.toMatchObject({
112+
success: true,
113+
previewSessions: [
114+
expect.objectContaining({
115+
id: 'preview-1',
116+
previewText: 'hello',
117+
previewVersion: 2,
118+
}),
119+
],
120+
status: 'active',
121+
})
122+
})
123+
80124
it('stops replay polling when run becomes cancelled', async () => {
81125
getLatestRunForStream
82126
.mockResolvedValueOnce({

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
createEvent,
1212
encodeSSEEnvelope,
1313
readEvents,
14+
readFilePreviewSessions,
1415
SSE_RESPONSE_HEADERS,
1516
} from '@/lib/copilot/request/session'
1617
import { toStreamBatchEvent } from '@/lib/copilot/request/session/types'
@@ -113,17 +114,28 @@ export async function GET(request: NextRequest) {
113114

114115
if (batchMode) {
115116
const afterSeq = afterCursor || '0'
116-
const events = await readEvents(streamId, afterSeq)
117+
const [events, previewSessions] = await Promise.all([
118+
readEvents(streamId, afterSeq),
119+
readFilePreviewSessions(streamId).catch((error) => {
120+
logger.warn('Failed to read preview sessions for stream batch', {
121+
streamId,
122+
error: error instanceof Error ? error.message : String(error),
123+
})
124+
return []
125+
}),
126+
])
117127
const batchEvents = events.map(toStreamBatchEvent)
118128
logger.info('[Resume] Batch response', {
119129
streamId,
120130
afterCursor: afterSeq,
121131
eventCount: batchEvents.length,
132+
previewSessionCount: previewSessions.length,
122133
runStatus: run.status,
123134
})
124135
return NextResponse.json({
125136
success: true,
126137
events: batchEvents,
138+
previewSessions,
127139
status: run.status,
128140
})
129141
}

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

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@ import { createLogger } from '@sim/logger'
44
import { and, eq, sql } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
7+
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
78
import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle'
89
import {
910
authenticateCopilotRequestSessionOnly,
1011
createBadRequestResponse,
1112
createInternalServerErrorResponse,
1213
createUnauthorizedResponse,
1314
} from '@/lib/copilot/request/http'
15+
import type { FilePreviewSession } from '@/lib/copilot/request/session'
1416
import { readEvents } from '@/lib/copilot/request/session/buffer'
17+
import { readFilePreviewSessions } from '@/lib/copilot/request/session/file-preview-session'
1518
import { type StreamBatchEvent, toStreamBatchEvent } from '@/lib/copilot/request/session/types'
1619
import { taskPubSub } from '@/lib/copilot/tasks'
1720
import { captureServerEvent } from '@/lib/posthog/server'
@@ -49,16 +52,37 @@ export async function GET(
4952

5053
let streamSnapshot: {
5154
events: StreamBatchEvent[]
55+
previewSessions: FilePreviewSession[]
5256
status: string
5357
} | null = null
5458

5559
if (chat.conversationId) {
5660
try {
57-
const events = await readEvents(chat.conversationId, '0')
61+
const [events, previewSessions] = await Promise.all([
62+
readEvents(chat.conversationId, '0'),
63+
readFilePreviewSessions(chat.conversationId).catch((error) => {
64+
logger.warn('Failed to read preview sessions for mothership chat', {
65+
chatId,
66+
conversationId: chat.conversationId,
67+
error: error instanceof Error ? error.message : String(error),
68+
})
69+
return []
70+
}),
71+
])
72+
const run = await getLatestRunForStream(chat.conversationId, userId).catch((error) => {
73+
logger.warn('Failed to fetch latest run for mothership chat snapshot', {
74+
chatId,
75+
conversationId: chat.conversationId,
76+
error: error instanceof Error ? error.message : String(error),
77+
})
78+
return null
79+
})
5880

5981
streamSnapshot = {
6082
events: events.map(toStreamBatchEvent),
61-
status: events.length > 0 ? 'active' : 'unknown',
83+
previewSessions,
84+
status:
85+
typeof run?.status === 'string' ? run.status : events.length > 0 ? 'active' : 'unknown',
6286
}
6387
} catch (error) {
6488
logger.warn('Failed to read stream snapshot for mothership chat', {

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import {
2929
useWorkspaceFileContent,
3030
} from '@/hooks/queries/workspace-files'
3131
import { useAutosave } from '@/hooks/use-autosave'
32-
import { useStreamingText } from '@/hooks/use-streaming-text'
3332
import { DataTable } from './data-table'
3433
import { PreviewPanel, resolvePreviewType } from './preview-panel'
3534

@@ -526,8 +525,7 @@ function TextEditor({
526525

527526
const isStreaming = isStreamInteractionLocked
528527
const isEditorReadOnly = isStreamInteractionLocked || !canEdit
529-
const revealedContent = useStreamingText(content, false)
530-
const renderedContent = isStreaming ? revealedContent : content
528+
const renderedContent = content
531529
const gutterWidthPx = useMemo(() => {
532530
const lineCount = renderedContent.split('\n').length
533531
return calculateGutterWidth(lineCount)
@@ -731,7 +729,7 @@ function TextEditor({
731729
const el = (shouldUseCodeRenderer ? codeScrollRef.current : textareaRef.current) ?? null
732730
if (!el) return
733731
el.scrollTop = el.scrollHeight
734-
}, [isStreaming, revealedContent, shouldUseCodeRenderer])
732+
}, [isStreaming, renderedContent, shouldUseCodeRenderer])
735733

736734
if (streamingContent === undefined) {
737735
if (isLoading) return DOCUMENT_SKELETON

0 commit comments

Comments
 (0)