Fix chat reactivity (markRaw), add Playwright tests with mock transport

Use markRaw to prevent Vue from deep-proxying the Chat class
instance, which broke its internal Vue refs. Add dedent for
multiline prompts.

Tests use mock transport by default. Set TEST_REAL_LLM=1 and
OPENROUTER_API_KEY to run against real OpenRouter.
This commit is contained in:
Danila Poyarkov 2026-03-01 11:30:30 +03:00
parent c18ced5dcf
commit f6434bc4f9
8 changed files with 215 additions and 42 deletions

View file

@ -20,6 +20,7 @@
"ai": "^6.0.105",
"canvaskit-wasm": "^0.40.0",
"culori": "^4.0.2",
"dedent": "^1.7.1",
"fflate": "^0.8.2",
"fzstd": "^0.1.1",
"reka-ui": "^2.8.2",
@ -492,6 +493,8 @@
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"dedent": ["dedent@1.7.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],

View file

@ -37,6 +37,7 @@
"ai": "^6.0.105",
"canvaskit-wasm": "^0.40.0",
"culori": "^4.0.2",
"dedent": "^1.7.1",
"fflate": "^0.8.2",
"fzstd": "^0.1.1",
"reka-ui": "^2.8.2",

View file

@ -1,30 +1,23 @@
<script setup lang="ts">
import { ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
import { computed, nextTick, ref, watch } from 'vue'
import { computed, markRaw, nextTick, ref, watch } from 'vue'
import APIKeySetup from '@/components/chat/APIKeySetup.vue'
import ChatInput from '@/components/chat/ChatInput.vue'
import ChatMessage from '@/components/chat/ChatMessage.vue'
import { useAIChat } from '@/composables/use-chat'
import type { DesignMessage } from '@/composables/use-chat'
import type { Chat } from '@ai-sdk/vue'
import type { UIMessage } from 'ai'
const { isConfigured, createChat } = useAIChat()
const { isConfigured, ensureChat } = useAIChat()
const chat = ref<Chat<DesignMessage> | null>(null)
const chat = ref<Chat<UIMessage> | null>(null)
const messagesEnd = ref<HTMLDivElement>()
const messages = computed(() => chat.value?.messages ?? [])
const status = computed(() => chat.value?.status ?? 'ready')
function ensureChat() {
if (!chat.value && isConfigured.value) {
chat.value = createChat()
}
return chat.value
}
function scrollToBottom() {
nextTick(() => {
messagesEnd.value?.scrollIntoView({ behavior: 'smooth', block: 'end' })
@ -34,9 +27,11 @@ function scrollToBottom() {
watch(messages, scrollToBottom, { deep: true })
function handleSubmit(text: string) {
const c = ensureChat()
if (!c) return
c.sendMessage({ text })
if (!chat.value) {
const c = ensureChat()
if (c) chat.value = markRaw(c)
}
chat.value?.sendMessage({ text }).catch(() => {})
}
function handleStop() {

View file

@ -48,7 +48,12 @@ const { activeTab } = useAIChat()
<DesignPanel />
</TabsContent>
<TabsContent value="ai" class="flex min-h-0 flex-1 flex-col">
<TabsContent
value="ai"
class="flex min-h-0 flex-1 flex-col"
:force-mount="true"
:hidden="activeTab !== 'ai'"
>
<ChatPanel />
</TabsContent>
</TabsRoot>

View file

@ -3,11 +3,11 @@ import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui
import { Markdown } from 'vue-stream-markdown'
import 'vue-stream-markdown/index.css'
import type { DesignMessage } from '@/composables/use-chat'
import type { UIMessage } from 'ai'
const { message } = defineProps<{ message: DesignMessage }>()
const { message } = defineProps<{ message: UIMessage }>()
function getTextContent(msg: DesignMessage): string {
function getTextContent(msg: UIMessage): string {
return msg.parts
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
.map((p) => p.text)
@ -34,7 +34,7 @@ function isToolPart(part: unknown): part is ToolPart {
)
}
function getToolParts(msg: DesignMessage): ToolPart[] {
function getToolParts(msg: UIMessage): ToolPart[] {
return msg.parts.filter(isToolPart)
}

View file

@ -1,9 +1,10 @@
import { Chat } from '@ai-sdk/vue'
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
import { DirectChatTransport, ToolLoopAgent } from 'ai'
import dedent from 'dedent'
import { computed, ref, watch } from 'vue'
import type { InferAgentUIMessage } from 'ai'
import type { UIMessage } from 'ai'
const API_KEY_STORAGE = 'open-pencil:openrouter-api-key'
const MODEL_STORAGE = 'open-pencil:model'
@ -16,7 +17,6 @@ export interface ModelOption {
}
export const MODEL_OPTIONS: ModelOption[] = [
// Design-optimized (vision + tool calling + strong spatial reasoning)
{
id: 'anthropic/claude-sonnet-4',
name: 'Claude Sonnet 4',
@ -31,9 +31,6 @@ export const MODEL_OPTIONS: ModelOption[] = [
tag: 'Long context'
},
{ id: 'openai/gpt-4.1', name: 'GPT-4.1', provider: 'OpenAI' },
// Fast & cheap
{ id: 'anthropic/claude-sonnet-4', name: 'Claude Sonnet 4', provider: 'Anthropic' },
{
id: 'google/gemini-2.5-flash-preview-05-20',
name: 'Gemini 2.5 Flash',
@ -41,8 +38,6 @@ export const MODEL_OPTIONS: ModelOption[] = [
tag: 'Fast'
},
{ id: 'openai/gpt-4.1-mini', name: 'GPT-4.1 Mini', provider: 'OpenAI', tag: 'Cheap' },
// Open source
{
id: 'deepseek/deepseek-chat-v3-0324:free',
name: 'DeepSeek V3',
@ -57,7 +52,6 @@ export const MODEL_OPTIONS: ModelOption[] = [
}
]
// Deduplicate by id, keeping first occurrence
const seen = new Set<string>()
const deduped: ModelOption[] = []
for (const m of MODEL_OPTIONS) {
@ -67,9 +61,14 @@ for (const m of MODEL_OPTIONS) {
}
}
export const MODELS = deduped
export const DEFAULT_MODEL = MODELS[0].id
const SYSTEM_PROMPT = dedent`
You are a design assistant inside OpenPencil, a Figma-like design editor.
Help users create and modify designs. Be concise and direct.
When describing changes, use specific design terminology.
`
const apiKey = ref(localStorage.getItem(API_KEY_STORAGE) ?? '')
const modelId = ref(localStorage.getItem(MODEL_STORAGE) ?? DEFAULT_MODEL)
const activeTab = ref<'design' | 'ai'>('design')
@ -88,7 +87,14 @@ watch(modelId, (id) => {
const isConfigured = computed(() => apiKey.value.length > 0)
function createAgent() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only mock transports don't implement full generics
let overrideTransport: (() => any) | null = null
let chat: Chat<UIMessage> | null = null
function createTransport() {
if (overrideTransport) return overrideTransport()
const openrouter = createOpenRouter({
apiKey: apiKey.value,
headers: {
@ -97,25 +103,32 @@ function createAgent() {
}
})
return new ToolLoopAgent({
const agent = new ToolLoopAgent({
model: openrouter(modelId.value),
instructions:
'You are a design assistant inside OpenPencil, a Figma-like design editor. ' +
'Help users create and modify designs. Be concise and direct.'
instructions: SYSTEM_PROMPT
})
return new DirectChatTransport({ agent })
}
type DesignAgent = ReturnType<typeof createAgent>
export type DesignMessage = InferAgentUIMessage<DesignAgent>
function createChat() {
function ensureChat(): Chat<UIMessage> | null {
if (!apiKey.value) return null
if (!chat) {
chat = new Chat<UIMessage>({
transport: createTransport()
})
}
return chat
}
const agent = createAgent()
function resetChat() {
chat = null
}
return new Chat<DesignMessage>({
transport: new DirectChatTransport({ agent })
})
if (typeof window !== 'undefined') {
window.__OPEN_PENCIL_SET_TRANSPORT__ = (factory) => {
overrideTransport = factory
}
}
export function useAIChat() {
@ -124,6 +137,7 @@ export function useAIChat() {
modelId,
activeTab,
isConfigured,
createChat
ensureChat,
resetChat
}
}

2
src/global.d.ts vendored
View file

@ -20,4 +20,6 @@ interface Window {
blob(): Promise<Blob>
}[]
>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
__OPEN_PENCIL_SET_TRANSPORT__?(factory: () => any): void
}

View file

@ -0,0 +1,153 @@
import { expect, test, type Page } from '@playwright/test'
import { CanvasHelper } from '../helpers/canvas'
const USE_REAL_LLM = process.env.TEST_REAL_LLM === '1'
const OPENROUTER_KEY = process.env.OPENROUTER_API_KEY ?? ''
let page: Page
let canvas: CanvasHelper
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
await page.goto('/')
canvas = new CanvasHelper(page)
await canvas.waitForInit()
if (!USE_REAL_LLM) {
await injectMockTransport(page)
}
})
test.afterAll(async () => {
await page.close()
})
async function injectMockTransport(page: Page) {
await page.evaluate(() => {
const setTransport = window.__OPEN_PENCIL_SET_TRANSPORT__
if (!setTransport) throw new Error('Transport override not available')
setTransport(() => ({
async sendMessages({
messages,
}: {
messages: Array<{ role: string; parts: Array<{ type: string; text?: string }> }>
}) {
const lastUser = [...messages].reverse().find((m) => m.role === 'user')
const text = lastUser?.parts?.find((p) => p.type === 'text')?.text ?? ''
const words = `I'll help you with: "${text}". Here's a mock response.`.split(' ')
return new ReadableStream({
start(controller) {
controller.enqueue({ type: 'start', messageId: 'mock-msg-1' })
controller.enqueue({ type: 'text-start', id: 'text-1' })
for (const word of words) {
controller.enqueue({ type: 'text-delta', id: 'text-1', delta: word + ' ' })
}
controller.enqueue({ type: 'text-end', id: 'text-1' })
controller.enqueue({ type: 'finish', finishReason: 'stop' })
controller.close()
},
})
},
async reconnectToStream() {
return null
},
}))
})
}
function chatTab() {
return page.getByRole('tab', { name: 'AI' })
}
function designTab() {
return page.getByRole('tab', { name: 'Design' })
}
function chatInput() {
return page.locator('input[placeholder="Describe a change…"]')
}
function apiKeyInput() {
return page.locator('input[placeholder="sk-or-…"]')
}
test('⌘J switches to AI tab', async () => {
await designTab().waitFor()
await page.keyboard.press('Meta+j')
await expect(chatTab()).toHaveAttribute('data-state', 'active')
})
test('⌘J switches back to Design tab', async () => {
await page.keyboard.press('Meta+j')
await expect(designTab()).toHaveAttribute('data-state', 'active')
})
test('clicking AI tab shows API key setup when no key set', async () => {
await chatTab().click()
await expect(apiKeyInput()).toBeVisible()
await expect(page.getByText('Enter your OpenRouter API key')).toBeVisible()
})
test('saving API key shows chat interface', async () => {
const key = USE_REAL_LLM ? OPENROUTER_KEY : 'sk-or-test-key-12345'
await apiKeyInput().fill(key)
await page.locator('button:has-text("Save")').click()
await expect(chatInput()).toBeVisible()
await expect(page.getByText('Describe what you want to create or change.')).toBeVisible()
})
test('empty input has disabled send button', async () => {
const sendButton = page.locator('button[type="submit"]')
await expect(sendButton).toBeDisabled()
})
test('typing enables send button', async () => {
await chatInput().fill('Make a red rectangle')
const sendButton = page.locator('button[type="submit"]')
await expect(sendButton).toBeEnabled()
})
test('Enter submits message and clears input', async () => {
await chatInput().fill('Make a red rectangle')
await chatInput().press('Enter')
await expect(page.getByText('Make a red rectangle', { exact: true })).toBeVisible({ timeout: 5000 })
await expect(chatInput()).toHaveValue('')
})
test('assistant responds', async () => {
if (USE_REAL_LLM) {
await expect(
page.locator('.chat-markdown, [class*="rounded-tl-md"]').first(),
).toBeVisible({ timeout: 30000 })
} else {
await expect(page.getByText('mock response', { exact: false })).toBeVisible({ timeout: 5000 })
}
})
test('model selector is visible and clickable', async () => {
const trigger = page.getByRole('combobox')
await expect(trigger).toBeVisible()
await trigger.click()
await expect(page.getByText('Claude Sonnet 4')).toBeVisible()
await expect(page.getByText('Recommended')).toBeVisible()
await expect(page.getByText('Free').first()).toBeVisible()
await page.keyboard.press('Escape')
})
test('switching tabs preserves chat', async () => {
await designTab().click()
await expect(designTab()).toHaveAttribute('data-state', 'active')
await chatTab().click()
await expect(page.getByText('Make a red rectangle', { exact: true })).toBeVisible()
})