openpencil/tests/helpers/chat/render-stream/gate.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

52 lines
1.5 KiB
TypeScript

import { until, useEventListener } from '@vueuse/core'
import { effectScope, ref, shallowRef } from 'vue'
/** Explicit checkpoints for visual assertions; no timing assumptions or private SDK hooks. */
export function createStreamGate<Chunk>(isCheckpoint: (chunk: Chunk) => boolean) {
const permits = ref(0)
const opened = ref(false)
const closed = ref(false)
const failure = shallowRef<Error>()
// Owns the abort listeners of the requests this gate follows.
const listeners = effectScope(true)
function close() {
closed.value = true
listeners.stop()
}
const transform = new TransformStream<Chunk, Chunk>({
async transform(chunk, controller) {
if (isCheckpoint(chunk)) {
await until(
() => opened.value || permits.value > 0 || closed.value || failure.value !== undefined
).toBe(true)
if (permits.value > 0) permits.value--
}
if (failure.value) throw failure.value
if (!closed.value) controller.enqueue(chunk)
},
flush: () => listeners.stop()
})
return {
transform,
follow(signal?: AbortSignal) {
if (!signal || closed.value) return
if (signal.aborted) close()
else listeners.run(() => useEventListener(signal, 'abort', close, { once: true }))
},
advance() {
permits.value++
},
open() {
opened.value = true
},
fail() {
failure.value = new Error('Provider disconnected')
listeners.stop()
},
close
}
}