openpencil/tests/helpers/chat/render-stream/model.ts
Danila Poyarkov 418457bfb5
feat: preview streamed JSX on the canvas (#692)
* feat: preview streamed JSX on the canvas

Project incomplete JSX into isolated scene graphs and disposable pictures without mutating the document or adding intermediate undo entries. Share placement with final rendering and cover lifecycle and placement parity with AI SDK mocks and visual tests.

* test: require partial input for unfinished coordinates

Assert the complete partial object so rejecting the entire input cannot satisfy the truncated-exponent regression test. Addresses CodeRabbit's review finding on #692.

* feat(ai): keep a chat run on its page across page switches

Page switches go through the editor's preparation flow, and the chat panel treated every preparation as a document change: it dropped its Chat and reloaded history, detaching the panel from a reply still in progress. The panel now keeps the live chat unless the tab or the conversation changes.

AI tools also followed the page on screen, so a user browsing mid-run sent the next edits elsewhere, and the agent's own switch_page affected only one call. A run now pins the page where the message started; switch_page moves the run and the user's view, and streamed previews stay attached to the run's page, which the renderer draws only while that page is on screen.

Page snapshots now restore the page they were taken of, so undoing an AI edit works while another page is visible.

* refactor(core): share picture recording and export preparation with previews

Preview recording reimplemented three pieces Core already had: world-bounds picture recording (also duplicated by render chunks and the retained backing), font and layout preparation (prepareForExport), and page subgraph extraction. Extract recordWorldPicture and withWorldViewport for all three recorders, reuse prepareForExport, and add extractPageContext and findPageChildId next to the other subgraph helpers instead of editing a cloned graph's nodes.

prepareForExport also kept the shared layout text measurer overridden across an await, so a concurrent layout could measure with the export renderer. withTextMeasurer scopes the override to the synchronous layout.

* fix(design-jsx): inline nested fragments in streamed previews

The streaming projection kept a nested fragment as an empty-type node, which rendered trees inline, so a preview of <Frame><>…</></Frame> failed with 'Unknown element: <>'.

* refactor(ai): schedule previews and gate test streams with VueUse

The preview controller hand-rolled a trailing timer and abort-listener cleanup, and the test stream gate a promise resolver and listener set. Use useDebounceFn with maxWait (a lone delta still flushes, unlike useThrottleFn with leading off), useEventListener, and until(). Share the mock token usage between chat tests.

* fix(ai): keep previews alive through document edits and slow builds

Document edits finished every preview call, and onInputStart never restarts one, so a render call committing while a second was still streaming ended the second call's preview for good. Edits now invalidate: drop the shown artifact and rebuild on the new document.

A build that finished after another delta arrived was discarded, so a steady stream that outpaced staging and recording never showed a preview. Show it, then render the newer revision.

* docs(changelog): separate the Fixed heading from its entries

Add the blank line markdownlint (MD022) expects after the heading, and drop the one that split the Fixed list in two.
2026-10-01 10:52:36 +04:00

100 lines
3.3 KiB
TypeScript

import { simulateReadableStream } from 'ai'
import { MockLanguageModelV4, mockValues } from 'ai/test'
import type { RenderPlacementInput } from '@open-pencil/core/design-jsx'
import { MOCK_USAGE } from '../usage'
import { createStreamGate } from './gate'
type ModelStream = Awaited<ReturnType<MockLanguageModelV4['doStream']>>
type ModelChunk = ModelStream['stream'] extends ReadableStream<infer Chunk> ? Chunk : never
export interface RenderStreamScenario {
jsx: string
placement?: RenderPlacementInput
outcome?: 'render' | 'provider-error'
/** Markers in serialized input after which the provider pauses, in order. */
pauseAfter: string[]
}
function argumentChunks(input: string, markers: string[]): string[] {
let offset = 0
const chunks: string[] = []
for (const marker of markers) {
const start = input.indexOf(marker, offset)
if (!marker || start === -1) throw new Error(`Stream checkpoint not found: ${marker}`)
const end = start + marker.length
chunks.push(input.slice(offset, end))
offset = end
}
if (offset === input.length) throw new Error('Leave a final input chunk for tool execution')
chunks.push(input.slice(offset))
return chunks
}
/** SDK-owned provider behavior with one test-owned pause gate around argument chunks. */
export function createRenderStreamModel(scenario: RenderStreamScenario) {
const input = JSON.stringify({ ...scenario.placement, jsx: scenario.jsx })
const gate = createStreamGate<ModelChunk>((chunk) => chunk.type === 'tool-input-delta')
const completion: ModelChunk[] =
scenario.outcome === 'provider-error'
? [{ type: 'error', error: new Error('Provider rejected the request') }]
: [
{ type: 'tool-input-end', id: 'preview-call' },
{ type: 'tool-call', toolCallId: 'preview-call', toolName: 'render', input },
{
type: 'finish',
usage: MOCK_USAGE,
finishReason: { unified: 'tool-calls', raw: 'tool_calls' }
}
]
const chunks: ModelChunk[] = [
{ type: 'stream-start', warnings: [] },
{ type: 'tool-input-start', id: 'preview-call', toolName: 'render' },
...argumentChunks(input, scenario.pauseAfter).map((delta): ModelChunk => ({
type: 'tool-input-delta',
id: 'preview-call',
delta
})),
...completion
]
const response: ModelChunk[] = [
{ type: 'stream-start', warnings: [] },
{ type: 'text-start', id: 'done' },
{ type: 'text-delta', id: 'done', delta: 'Rendered.' },
{ type: 'text-end', id: 'done' },
{ type: 'finish', usage: MOCK_USAGE, finishReason: { unified: 'stop', raw: 'stop' } }
]
const nextResponse = mockValues<ModelStream>(
{
stream: simulateReadableStream({
chunks,
initialDelayInMs: null,
chunkDelayInMs: null
}).pipeThrough(gate.transform)
},
{
stream: simulateReadableStream({
chunks: response,
initialDelayInMs: null,
chunkDelayInMs: null
})
}
)
const model = new MockLanguageModelV4({
async doStream({ abortSignal }) {
gate.follow(abortSignal)
return nextResponse()
}
})
return {
model,
ready: () => model.doStreamCalls.length > 0,
requestCount: () => model.doStreamCalls.length,
advance: gate.advance,
complete: gate.open,
fail: gate.fail,
dispose: gate.close
}
}