* fix(vue): keep the command palette open when a command opens a step CommandPaletteRoot emitted select for every item, including one that only opens its children, so a host that closes on select closed the palette instead of showing the step. useCommandPalette.select now reports whether a command ran, and the root emits only then. Disabled items were marked only with Reka's data-disabled; expose aria-disabled so assistive technology announces them. * feat(app): jump between pages from the command palette The palette had no way to reach a page. It now lists the pages visited recently in the tab, offers a Go to page step with every page, and finds any page by name. Recent pages are tracked per editor session from page changes and reset when the document is replaced. Palette items can be search-only, so pages beyond the recent ones appear only when the query matches them. The divider-page rule moves out of PageListRoot so the palette skips dividers the same way, and useCommandPalette is exported from the package root. * feat(canvas): draw agents' cursors as outlined sparkles Editor state's remoteCursors becomes presenceCursors with a kind, since the list now includes local agents. People keep the filled arrow; an agent is a sparkle outlined in its owner's color, with an outlined name pill, so whose agent it is reads from the outline. Cursor drawing moves out of the pen overlay into canvas/overlays/presence.ts. * feat(app): publish AI agent presence to collaborators The built-in chat now appears as an agent with a callsign while it replies, at the nodes its tools touch on the run's page, and goes idle (off the canvas) when the reply ends. Agents live in a per-document presence registry and are published in their owner's awareness state, so collaborators see each other's agents in the owner's color; the payload is metadata only. Peer awareness was cast without checks. It is now validated with Valibot, invalid fields are dropped rather than the peer, and names, selections, and agent counts are bounded. * feat(app): follow agents and list them in the share panel Following lived in collab and only knew people. It moves into the presence registry with a person-or-agent target, so you can follow anyone's agent, including your own outside a room: the view goes to the agent's page and keeps its cursor centered, stays attached while it idles between replies, and lets go when it leaves. A new editor action, centerOn, replaces reading the canvas size from the DOM. Peer cursors keep their zoom so following a person still matches it. The share panel lists everyone in the room with their agents, each with its status, page, and a follow toggle, and your own agents can be renamed inline. CollabPanel moves to collab-panel, and the two-browser relay helpers move out of the collab spec into tests/helpers/collab. * feat(app): show who works on each page Agents now publish the page they work on, set when a reply starts on its pinned page and moved by switch_page, so a page is marked before the agent's first edit. presenceByPage groups people and working agents by page. The Pages panel marks those pages with people's dots and agents' outlined sparkles in their owner colors, the command palette names who is on each page, and the chat says which page a reply is working on, with Go to page, while you view another one. * docs(collaboration): list the agent model among shared presence * test(app): stories for page presence markers and the chat's run location The run location notice reads app state, so it moves into useChatRunLocation and the component takes the agent and page as props. * test(vue): a canvas story for presence cursors Storybook now serves CanvasKit, so a story can render the real canvas: people's arrows and agents' outlined sparkles, with controls for names, colors, and zoom. * feat(canvas): mark agents with a sparkle label instead of a sparkle cursor A sparkle on its own did not read as a pointer. Agents now point with the same filled arrow as people, in their owner's color, and their outlined label starts with a sparkle. * refactor(app): split the collaboration theme by component One 18-slot theme served five components that each used a few slots, with variants that applied to one slot. Avatars, the share button, the presence list, page markers, and the mobile presence popover now have their own themes, exported as tv() like the rest of src/theme. * fix(app): truncate an agent's status before its name in the presence list In a narrow share panel the status kept its width and the callsign shrank to its first letter. * feat(app): right-align page badges in a trailing area of the page row Presence markers followed the page name. The row now has a trailing area, right-aligned with its own spacing, where markers and later page badges go. * fix(app): key page markers by person or agent, not by name Two people with the same name on a page, such as two Anonymous peers, gave page markers duplicate keys. Entries now carry a stable id.
179 lines
7 KiB
TypeScript
179 lines
7 KiB
TypeScript
import { CanvasHelper } from '#tests/helpers/canvas'
|
|
import { expect, test } from '#tests/helpers/chat/fixture'
|
|
import {
|
|
addPage,
|
|
documentState,
|
|
previewKey,
|
|
setupCanvas,
|
|
switchPage
|
|
} from '#tests/helpers/chat/render-preview'
|
|
import { installRenderStream } from '#tests/helpers/chat/render-stream'
|
|
|
|
const opening =
|
|
'<Frame name="Streaming card" w={280} h={180} bg="#20242f" flex="col" p={20} gap={12} rounded={16}><Text size={24} color="#ffffff">Streaming'
|
|
const closing = ' preview</Text><Rect w={240} h={36} bg="#9747ff" rounded={8}/></Frame>'
|
|
const scenario = {
|
|
jsx: opening + closing,
|
|
placement: { x: 80, y: 80 },
|
|
pauseAfter: ['>Streaming', '</Frame>']
|
|
}
|
|
|
|
test('renders streamed JSX without document mutations, then commits once with undo', async ({
|
|
configuredChat: chat
|
|
}) => {
|
|
await setupCanvas(chat.page)
|
|
const stream = await installRenderStream(chat.page, scenario)
|
|
try {
|
|
await chat.submit('Render a card')
|
|
await expect.poll(() => stream.evaluate((s) => s.ready())).toBe(true)
|
|
await stream.evaluate((s) => s.advance())
|
|
await expect.poll(() => previewKey(chat.page)).not.toBe('')
|
|
expect(await documentState(chat.page)).toEqual({ children: [], undo: false })
|
|
const canvas = new CanvasHelper(chat.page)
|
|
await canvas.waitForRender()
|
|
expect(await canvas.screenshotCanvasRegion(640, 400)).toMatchSnapshot(
|
|
'jsx-streaming-open-text.png'
|
|
)
|
|
const first = await previewKey(chat.page)
|
|
await stream.evaluate((s) => s.advance())
|
|
await expect.poll(() => previewKey(chat.page)).not.toBe(first)
|
|
await canvas.waitForRender()
|
|
expect(await canvas.screenshotCanvasRegion(640, 400)).toMatchSnapshot(
|
|
'jsx-streaming-complete-preview.png'
|
|
)
|
|
expect(await documentState(chat.page)).toEqual({ children: [], undo: false })
|
|
await chat.page.evaluate(() => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('Editor unavailable')
|
|
store.state.panX = 30
|
|
store.state.panY = 20
|
|
store.state.zoom = 0.75
|
|
store.requestRepaint()
|
|
})
|
|
await canvas.waitForRender()
|
|
expect(await canvas.screenshotCanvasRegion(640, 400)).toMatchSnapshot(
|
|
'jsx-streaming-zoomed-preview.png'
|
|
)
|
|
await stream.evaluate((s) => s.complete())
|
|
await expect(chat.assistantMessage()).toContainText('Rendered.')
|
|
expect(await stream.evaluate((s) => s.requestCount())).toBe(2)
|
|
await expect.poll(() => previewKey(chat.page)).toBe('')
|
|
const committed = await documentState(chat.page)
|
|
expect(committed.children).toHaveLength(1)
|
|
expect(committed.undo).toBe(true)
|
|
await chat.page.evaluate(() => window.openPencil?.getStore?.().undoAction())
|
|
expect(await documentState(chat.page)).toEqual({ children: [], undo: false })
|
|
} finally {
|
|
await stream.evaluate((s) => s.dispose())
|
|
await stream.dispose()
|
|
}
|
|
})
|
|
|
|
test('preserves a replacement target until completion and restores it on undo', async ({
|
|
configuredChat: chat
|
|
}) => {
|
|
await setupCanvas(chat.page)
|
|
const originalId = await chat.page.evaluate(() => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('Editor unavailable')
|
|
const node = store.graph.createNode('FRAME', store.state.currentPageId, {
|
|
name: 'Placeholder',
|
|
x: 80,
|
|
y: 80,
|
|
width: 280,
|
|
height: 180
|
|
})
|
|
store.requestRender()
|
|
return node.id
|
|
})
|
|
const stream = await installRenderStream(chat.page, {
|
|
...scenario,
|
|
placement: { replace_id: originalId, x: 80, y: 80 }
|
|
})
|
|
try {
|
|
await chat.submit('Replace the placeholder')
|
|
await expect.poll(() => stream.evaluate((s) => s.ready())).toBe(true)
|
|
await stream.evaluate((s) => s.advance())
|
|
await expect.poll(() => previewKey(chat.page)).not.toBe('')
|
|
expect(await documentState(chat.page)).toEqual({ children: [originalId], undo: false })
|
|
await stream.evaluate((s) => s.complete())
|
|
await expect(chat.assistantMessage()).toContainText('Rendered.')
|
|
expect((await documentState(chat.page)).children).not.toContain(originalId)
|
|
await chat.page.evaluate(() => window.openPencil?.getStore?.().undoAction())
|
|
expect(await documentState(chat.page)).toEqual({ children: [originalId], undo: false })
|
|
} finally {
|
|
await stream.evaluate((s) => s.dispose())
|
|
await stream.dispose()
|
|
}
|
|
})
|
|
|
|
test('keeps the run and its preview on their page while the user views another', async ({
|
|
configuredChat: chat
|
|
}) => {
|
|
await setupCanvas(chat.page)
|
|
const pages = await addPage(chat.page, 'Other')
|
|
const stream = await installRenderStream(chat.page, scenario)
|
|
try {
|
|
await chat.submit('Render a card')
|
|
await expect.poll(() => stream.evaluate((s) => s.ready())).toBe(true)
|
|
await stream.evaluate((s) => s.advance())
|
|
await expect.poll(() => previewKey(chat.page)).not.toBe('')
|
|
|
|
await switchPage(chat.page, pages.added)
|
|
expect(await previewKey(chat.page)).toBe('')
|
|
await expect(chat.page.getByTestId('chat-stop-button')).toBeVisible()
|
|
|
|
// Away from the run's page: the chat says where it works, and the page list marks it.
|
|
const location = chat.page.getByTestId('chat-run-location')
|
|
await expect(location).toContainText('is working on “Page 1”')
|
|
await expect(
|
|
chat.page
|
|
.getByTestId('pages-row')
|
|
.filter({ hasText: 'Page 1' })
|
|
.getByTestId('presence-markers')
|
|
).toBeVisible()
|
|
|
|
await location.getByRole('button', { name: 'Go to page' }).click()
|
|
await expect.poll(() => previewKey(chat.page)).not.toBe('')
|
|
await expect(location).toHaveCount(0)
|
|
|
|
await switchPage(chat.page, pages.added)
|
|
await stream.evaluate((s) => s.complete())
|
|
await expect(chat.assistantMessage()).toContainText('Rendered.')
|
|
expect((await documentState(chat.page, pages.current)).children).toHaveLength(1)
|
|
expect((await documentState(chat.page, pages.added)).children).toHaveLength(0)
|
|
expect(
|
|
await chat.page.evaluate(() => window.openPencil?.getStore?.().state.currentPageId)
|
|
).toBe(pages.added)
|
|
} finally {
|
|
await stream.evaluate((s) => s.dispose())
|
|
await stream.dispose()
|
|
}
|
|
})
|
|
|
|
for (const outcome of ['cancel', 'error', 'disconnect'] as const) {
|
|
test(`removes speculative JSX on ${outcome} without committing it`, async ({
|
|
configuredChat: chat
|
|
}) => {
|
|
await setupCanvas(chat.page)
|
|
const stream = await installRenderStream(chat.page, {
|
|
...scenario,
|
|
outcome: outcome === 'error' ? 'provider-error' : 'render'
|
|
})
|
|
try {
|
|
await chat.submit('Render a card')
|
|
await expect.poll(() => stream.evaluate((s) => s.ready())).toBe(true)
|
|
await stream.evaluate((s) => s.advance())
|
|
await expect.poll(() => previewKey(chat.page)).not.toBe('')
|
|
if (outcome === 'cancel') await chat.page.getByTestId('chat-stop-button').click()
|
|
else if (outcome === 'error') await stream.evaluate((s) => s.complete())
|
|
else await stream.evaluate((s) => s.fail())
|
|
await expect.poll(() => previewKey(chat.page)).toBe('')
|
|
expect(await documentState(chat.page)).toEqual({ children: [], undo: false })
|
|
} finally {
|
|
await stream.evaluate((s) => s.dispose())
|
|
await stream.dispose()
|
|
}
|
|
})
|
|
}
|