diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 597de19b1..b901aafb6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -102,6 +102,9 @@ jobs:
- name: Typecheck application and SDKs
run: bun run typecheck
+ - name: Typecheck test suites
+ run: bun run check:test-types
+
- name: Enforce architecture and type-shape boundaries
run: bun run check:arch && bun run check:test-homes && bun run test:type-shapes
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ca6859c6f..d6da4d79c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -56,7 +56,7 @@
- Choose how much an AI model thinks for each message from the chat composer, from Off to Extra high or the provider default. Anthropic, Google, DeepSeek, OpenAI, OpenRouter, and compatible models apply it, where reasoning effort previously reached only OpenAI and OpenRouter models. A model profile's thinking level, which replaces its free-text reasoning effort, sets the starting choice, and finished reasoning shows how long the model thought.
- Read AI tool calls at a glance: each call shows a one-line summary and buttons that bring the layers it touched into view, even on another page. Expanded, it shows highlighted JSX, scripts, and JSON input and output, and exported images inline. A render call's JSX streams in as the model writes it, and a long run folds its earlier steps into one row that lists them by name and counts failures.
- Review what each AI edit changed from its tool call in the chat: a before and after image split by a draggable divider, or the changed pixels highlighted, and a diff of the layers' JSX. Both are saved with the conversation. Settings → Chat → Change previews sets the image size (240, 480, or 960 px) or turns images off to keep only the JSX diff. The chat AI can check its own work the same way with `diff_changes`, which compares the page or a layer with its state before the run edited it as a patch that `diff_apply` can replay.
-- Revert an AI reply's edits from the chat while nothing has been edited since, regenerate the last reply, or edit the last message and send it again; regenerating and resending undo the replaced reply's edits first when they can. A reverted reply stays in the chat marked *Changes reverted*, also after reopening the conversation, and your next message tells the AI that those edits are gone, so it does not build on them; **Restore changes** on the reply, or Edit → Redo, brings the edits back and removes the mark while nothing has been edited since.
+- Revert an AI reply's edits from the chat while nothing has been edited since, regenerate the last reply, or edit the last message and send it again; regenerating and resending undo the replaced reply's edits first when they can. A reverted reply stays in the chat, dimmed and marked *Changes reverted*, also after reopening the conversation, and your next message tells the AI that those edits are gone, so it does not build on them; **Restore changes** on the reply, or Edit → Redo, brings the edits back and removes the mark while nothing has been edited since.
- Preview designs progressively on the canvas as direct AI providers stream JSX, without saving partial designs or adding intermediate undo steps. A preview stays with its page: it hides while you view another page and returns when you come back.
- Write design trees as TSX with `@open-pencil/design-jsx` as the JSX import source, and render them with `renderTree`.
- Swap the component behind an instance with `instance.swapComponent(component)` in the plugin API, as in Figma.
@@ -75,6 +75,7 @@
### Changed
+- Confirm deleting an AI conversation in the standard confirmation dialog, which names the conversation, instead of a prompt inside the chat panel.
- Run Pi with the providers you signed in to in the Pi CLI and Pi's default model, so an AI Gateway key and a model ID are optional. The Pi model editor shows whether the Harness companion and MCP server are installed and match the app, and a chat whose Harness companion is missing, or whose companion or MCP server does not match the app, says what to fix and offers guided setup instead of failing with a generic error, and the message and its attachments stay in the composer.
- Point Codex install instructions at `@agentclientprotocol/codex-acp`, which replaces `@zed-industries/codex-acp` and provides the same `codex-acp` command.
- The desktop app shows an available update in a Software Update window with formatted, scrollable release notes, a link to the full notes, and download progress you can cancel, instead of a system dialog that showed raw Markdown and could grow taller than the screen (#743). On macOS and Linux the update installs first and you choose when to restart; restarting, and on Windows installing, first asks about unsaved documents as Quit does.
@@ -136,6 +137,7 @@
- Show `.fig` thumbnails in the desktop app's recent files, which the app was not permitted to read.
- Give paints set through the plugin API, `eval`, and AI and MCP scripts an opacity of 1 and make them visible when the script leaves those out, as Figma does. Such paints were stored without them, which the Design panel could not show.
- Keep a layer's other plugin data when you pick or clear a colour in OkHCL. Picking one rewrote every plugin-data entry on the layer, including other plugins' and its export settings, as OkHCL data.
+- Keep a model picker in Settings → Models open when you open it while the model editor is still sliding in. Focus jumped to the editor's first field once the animation ended, which closed the picker.
- Show variable-bound colours and numbers correctly when a `.fig` exported from OpenPencil opens in Figma. Figma draws the value a bound field stores until something makes it resolve the variable again, and exports stored the colour from before the binding, so a bound fill appeared in its old colour. Each bound field is now written as it resolves in its layer's mode, or in the collection's default mode when the layer sets none.
- Give strokes the same paints fills have. A `.fig` file's gradient or image stroke imported as opaque black, because a stroke could hold only one color; it now keeps its stops, transform, and image, and renders and saves the way the same paint does as a fill. The stroke panel opens the fill picker, so you can give a stroke a gradient or an image and its weight, align, cap, join, and dashes stay as they were ([#797](https://github.com/open-pencil/open-pencil/issues/797)).
- Stop showing a “signal is aborted without reason” error when you switch pages again before the previous page has finished loading.
diff --git a/packages/vue/src/i18n/locales/de/ai.json b/packages/vue/src/i18n/locales/de/ai.json
index a4436157b..c31fa45e7 100644
--- a/packages/vue/src/i18n/locales/de/ai.json
+++ b/packages/vue/src/i18n/locales/de/ai.json
@@ -162,6 +162,7 @@
"saveChatTitle": "Speichern",
"cancelChatAction": "Abbrechen",
"deleteChatConfirmation": "Diesen Chat endgültig löschen?",
+ "deleteChatDescription": "„{title}“ und die Nachrichten darin werden entfernt. Das lässt sich nicht rückgängig machen.",
"chatDocumentHint": "Öffne das Dokument des Chats, um fortzufahren.",
"chatReadOnly": "Dieser Chat gehört zu einem anderen Dokument. Öffne es, um fortzufahren.",
"chatAgentReadOnly": "Der Agentenverlauf wurde gespeichert, aber die Sitzung kann hier noch nicht fortgesetzt werden. Starte einen neuen Chat.",
diff --git a/packages/vue/src/i18n/locales/es/ai.json b/packages/vue/src/i18n/locales/es/ai.json
index 5ddcc9be2..55821796d 100644
--- a/packages/vue/src/i18n/locales/es/ai.json
+++ b/packages/vue/src/i18n/locales/es/ai.json
@@ -162,6 +162,7 @@
"saveChatTitle": "Guardar",
"cancelChatAction": "Cancelar",
"deleteChatConfirmation": "¿Eliminar este chat permanentemente?",
+ "deleteChatDescription": "«{title}» y sus mensajes se eliminan. Esta acción no se puede deshacer.",
"chatDocumentHint": "Abre el documento del chat para continuar.",
"chatReadOnly": "Este chat pertenece a otro documento. Ábrelo para continuar.",
"chatAgentReadOnly": "La conversación del agente está guardada, pero todavía no se puede reanudar su sesión aquí. Inicia un nuevo chat.",
diff --git a/packages/vue/src/i18n/locales/fr/ai.json b/packages/vue/src/i18n/locales/fr/ai.json
index 0a11d4084..5de61ddfa 100644
--- a/packages/vue/src/i18n/locales/fr/ai.json
+++ b/packages/vue/src/i18n/locales/fr/ai.json
@@ -162,6 +162,7 @@
"saveChatTitle": "Enregistrer",
"cancelChatAction": "Annuler",
"deleteChatConfirmation": "Supprimer définitivement cette discussion ?",
+ "deleteChatDescription": "« {title} » et ses messages sont supprimés. Cette action est irréversible.",
"chatDocumentHint": "Ouvrez le document de la discussion pour continuer.",
"chatReadOnly": "Cette discussion appartient à un autre document. Ouvrez-le pour continuer.",
"chatAgentReadOnly": "La conversation avec l’agent est enregistrée, mais sa session ne peut pas encore reprendre ici. Commencez une nouvelle discussion.",
diff --git a/packages/vue/src/i18n/locales/it/ai.json b/packages/vue/src/i18n/locales/it/ai.json
index 88fa0b292..1f4b0b98d 100644
--- a/packages/vue/src/i18n/locales/it/ai.json
+++ b/packages/vue/src/i18n/locales/it/ai.json
@@ -162,6 +162,7 @@
"saveChatTitle": "Salva",
"cancelChatAction": "Annulla",
"deleteChatConfirmation": "Eliminare definitivamente questa chat?",
+ "deleteChatDescription": "«{title}» e i suoi messaggi vengono eliminati. L’operazione non può essere annullata.",
"chatDocumentHint": "Apri il documento della chat per continuare.",
"chatReadOnly": "Questa chat appartiene a un altro documento. Aprilo per continuare.",
"chatAgentReadOnly": "La conversazione con l’agente è salvata, ma non è ancora possibile riprendere la sessione qui. Avvia una nuova chat.",
diff --git a/packages/vue/src/i18n/locales/ja/ai.json b/packages/vue/src/i18n/locales/ja/ai.json
index 5d8a95950..b01cac945 100644
--- a/packages/vue/src/i18n/locales/ja/ai.json
+++ b/packages/vue/src/i18n/locales/ja/ai.json
@@ -162,6 +162,7 @@
"saveChatTitle": "保存",
"cancelChatAction": "キャンセル",
"deleteChatConfirmation": "このチャットを完全に削除しますか?",
+ "deleteChatDescription": "「{title}」とそのメッセージは削除されます。元に戻すことはできません。",
"chatDocumentHint": "続けるにはチャットのドキュメントを開いてください。",
"chatReadOnly": "このチャットは別のドキュメントに属しています。続けるにはそのドキュメントを開いてください。",
"chatAgentReadOnly": "エージェントとの会話は保存されていますが、ここではまだセッションを再開できません。新しいチャットを開始してください。",
diff --git a/packages/vue/src/i18n/locales/pl/ai.json b/packages/vue/src/i18n/locales/pl/ai.json
index 4e68a39f0..afd231958 100644
--- a/packages/vue/src/i18n/locales/pl/ai.json
+++ b/packages/vue/src/i18n/locales/pl/ai.json
@@ -162,6 +162,7 @@
"saveChatTitle": "Zapisz",
"cancelChatAction": "Anuluj",
"deleteChatConfirmation": "Trwale usunąć ten czat?",
+ "deleteChatDescription": "„{title}” i jej wiadomości zostaną usunięte. Tej operacji nie można cofnąć.",
"chatDocumentHint": "Otwórz dokument czatu, aby kontynuować.",
"chatReadOnly": "Ten czat należy do innego dokumentu. Otwórz go, aby kontynuować.",
"chatAgentReadOnly": "Rozmowa z agentem została zapisana, ale nie można tu jeszcze wznowić jego sesji. Rozpocznij nowy czat.",
diff --git a/packages/vue/src/i18n/locales/ru/ai.json b/packages/vue/src/i18n/locales/ru/ai.json
index d865ae360..2c01980cc 100644
--- a/packages/vue/src/i18n/locales/ru/ai.json
+++ b/packages/vue/src/i18n/locales/ru/ai.json
@@ -162,6 +162,7 @@
"saveChatTitle": "Сохранить",
"cancelChatAction": "Отмена",
"deleteChatConfirmation": "Удалить этот чат навсегда?",
+ "deleteChatDescription": "«{title}» и её сообщения будут удалены. Это действие нельзя отменить.",
"chatDocumentHint": "Откройте документ чата, чтобы продолжить.",
"chatReadOnly": "Этот чат относится к другому документу. Откройте его, чтобы продолжить.",
"chatAgentReadOnly": "Переписка с агентом сохранена, но восстановление его сессии пока недоступно. Начните новый чат.",
diff --git a/packages/vue/src/i18n/locales/zh-cn/ai.json b/packages/vue/src/i18n/locales/zh-cn/ai.json
index 47cd58fdd..ab4415fd2 100644
--- a/packages/vue/src/i18n/locales/zh-cn/ai.json
+++ b/packages/vue/src/i18n/locales/zh-cn/ai.json
@@ -162,6 +162,7 @@
"saveChatTitle": "保存",
"cancelChatAction": "取消",
"deleteChatConfirmation": "永久删除此聊天?",
+ "deleteChatDescription": "“{title}”及其消息将被删除,且无法撤销。",
"chatDocumentHint": "打开聊天所属的文档以继续。",
"chatReadOnly": "此聊天属于另一个文档。打开该文档以继续。",
"chatAgentReadOnly": "与代理的对话已保存,但暂时无法在此恢复会话。请新建聊天。",
diff --git a/packages/vue/src/i18n/messages/ai.ts b/packages/vue/src/i18n/messages/ai.ts
index 45931a5f0..3bbf4b58f 100644
--- a/packages/vue/src/i18n/messages/ai.ts
+++ b/packages/vue/src/i18n/messages/ai.ts
@@ -30,6 +30,7 @@ export const aiMessageDefaults = {
saveChatTitle: 'Save',
cancelChatAction: 'Cancel',
deleteChatConfirmation: 'Delete this conversation permanently?',
+ deleteChatDescription: params('“{title}” and its messages are removed. This cannot be undone.'),
chatDocumentHint: 'Open a chat’s document to continue it.',
chatReadOnly: 'This conversation belongs to another document. Open its document to continue.',
chatAgentReadOnly:
diff --git a/src/components/chat/ChatHistory.vue b/src/components/chat/ChatHistory.vue
index 660b40586..25fed0c01 100644
--- a/src/components/chat/ChatHistory.vue
+++ b/src/components/chat/ChatHistory.vue
@@ -18,6 +18,7 @@ import { useI18n } from '@open-pencil/vue'
import AppButton from '@/components/ui/button/AppButton.vue'
import IconButton from '@/components/ui/button/IconButton.vue'
+import { AppConfirmationDialog } from '@/components/ui/dialog'
import { menuItem, useMenuUI } from '@/components/ui/menu/menu'
import Tip from '@/components/ui/overlay/Tip.vue'
import iconButtonTheme from '@/theme/button/icon-button'
@@ -201,14 +202,15 @@ function closeMenu(event: Event) {
}}
-
-
{{ ai.deleteChatConfirmation }}
-
-
{{ ai.cancelChatAction }}{{
- ai.deleteChat
- }}
-
-
+
diff --git a/src/components/chat/ChatMessage.vue b/src/components/chat/ChatMessage.vue
index 60c1d3c3b..7acf4ff96 100644
--- a/src/components/chat/ChatMessage.vue
+++ b/src/components/chat/ChatMessage.vue
@@ -78,6 +78,7 @@ async function copyResponse(): Promise {
// The AI SDK updates parts in place and replaces only the message, so copy each part for
// the cards' computed state to see the new values.
+const reverted = computed(() => revertOf(message) !== null)
const groups = computed(() => groupMessageParts(message.parts.map((part) => ({ ...part }))))
function groupKey(group: MessagePartGroup): string {
@@ -95,50 +96,57 @@ function groupKey(group: MessagePartGroup): string {
:class="message.role === 'user' ? 'max-w-[85%]' : ''"
>
-
-
+
+
+
+
-
-
+
+
-
-
-
-
+
-
-
-
-
-
+
+
+
+
+
+
+
+
-import { ref, onUnmounted } from 'vue'
+import { ref, onUnmounted, useTemplateRef } from 'vue'
import { HARNESS_PROVIDER_ID } from '@open-pencil/core/constants'
import { useI18n } from '@open-pencil/vue'
@@ -17,6 +17,14 @@ import PanelDrillIn from '@/components/ui/panel/PanelDrillIn.vue'
const { ai, collaboration, common } = useI18n()
const editing = defineModel('editing', { default: false })
const editingProfileId = ref()
+const editor = useTemplateRef('editor')
+
+/** The editor opens on its name field rather than the back control. */
+function focusEditor(event: Event): void {
+ if (!editor.value) return
+ event.preventDefault()
+ editor.value.focus()
+}
onUnmounted(() => {
editing.value = false
})
@@ -52,9 +60,16 @@ const { profiles, statusByConnection, refreshStatuses } = useModelSettings()
-
+
()
const emit = defineEmits<{ done: []; deleted: [] }>()
const { ai, common, credentials, settings } = useI18n()
const formElement = useTemplateRef('formElement')
+const nameInput = useTemplateRef('nameInput')
+defineExpose({ focus: () => nameInput.value?.focus({ preventScroll: true }) })
const keyInput = ref('')
const deleteOpen = ref(false)
const profile = useModelProfileEditor({ profileId, keyInput, labels: ai })
@@ -122,6 +124,7 @@ async function remove() {
@blur="feedback.blur('name')"
>
+import { FocusScope } from 'reka-ui'
import { tv } from 'tailwind-variants'
-import { nextTick, ref, useTemplateRef, watch } from 'vue'
+import { ref } from 'vue'
import AppButton from '@/components/ui/button/AppButton.vue'
import type { ComponentUI } from '@/components/ui/types'
@@ -9,7 +10,8 @@ import drillInTheme from '@/theme/panel/drill-in'
/**
* A list with a detail view that slides in over it. The back control names the view it returns
- * to, like "‹ Models"; focus moves to the detail's first field and back to what opened it.
+ * to, like "‹ Models". Focus moves into the detail as it opens, to the back control unless
+ * `open-auto-focus` is prevented to focus a field instead, and back to what opened it.
*/
const { open, back, parent, ui } = defineProps<{
open: boolean
@@ -19,63 +21,30 @@ const { open, back, parent, ui } = defineProps<{
parent: string
ui?: ComponentUI
}>()
-const emit = defineEmits<{ back: [] }>()
+const emit = defineEmits<{ back: []; openAutoFocus: [event: Event] }>()
const styles = tv(drillInTheme)()
-const detail = useTemplateRef('detail')
/**
* The list shows under the detail while it slides, then hides once covered. It is inert from the
* moment the detail opens, and a closing detail is inert while it slides away, so the two never
* share the tab order.
*/
const covered = ref(false)
-let returnFocus: HTMLElement | null = null
-
-watch(
- () => open,
- (isOpen) => {
- if (isOpen && document.activeElement instanceof HTMLElement)
- returnFocus = document.activeElement
- }
-)
-
-const FIELD = '[data-slot="body"] :is(input:not([type="hidden"]), textarea, select):not([disabled])'
-
-/** The detail's first field, or its back control when it has none. */
-async function focusDetail() {
- covered.value = true
- await nextTick()
- const target =
- detail.value?.querySelector(FIELD) ??
- detail.value?.querySelector('[data-slot="back"]')
- target?.focus({ preventScroll: true })
-}
function uncover(element: Element) {
if (element instanceof HTMLElement) element.inert = true
covered.value = false
}
-
-async function restoreFocus() {
- await nextTick()
- if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true })
- returnFocus = null
-}
-
-
+
@@ -89,7 +58,7 @@ async function restoreFocus() {
-
+
diff --git a/src/theme/chat/history.ts b/src/theme/chat/history.ts
index 52dd55041..0c57d7689 100644
--- a/src/theme/chat/history.ts
+++ b/src/theme/chat/history.ts
@@ -23,7 +23,6 @@ export const chatHistoryTheme = tv({
label: 'block truncate text-xs',
empty: 'px-2 py-4 text-center text-xs text-muted',
form: 'space-y-2 border-t border-border p-2',
- actions: 'flex justify-end gap-1',
- confirmation: 'text-xs text-surface'
+ actions: 'flex justify-end gap-1'
}
})
diff --git a/tests/e2e/chat/file-identity.spec.ts b/tests/e2e/chat/file-identity.spec.ts
index 737637aad..f8885b7db 100644
--- a/tests/e2e/chat/file-identity.spec.ts
+++ b/tests/e2e/chat/file-identity.spec.ts
@@ -1,5 +1,14 @@
import { test, expect } from '@playwright/test'
+// Playwright's bundled Chromium on macOS closes the page when a FileSystemFileHandle is read
+// back from IndexedDB, before any app code runs; installed Google Chrome on the same machine
+// reads and compares it correctly.
+test.skip(
+ ({ browserName, channel }) =>
+ browserName === 'chromium' && !channel && process.platform === 'darwin',
+ "Playwright's bundled Chromium closes the page when reading a file handle back from IndexedDB on macOS"
+)
+
test('browser file identity survives reopening and distinguishes same-named files', async ({
page
}) => {
diff --git a/tests/e2e/chat/menu.spec.ts b/tests/e2e/chat/menu.spec.ts
index b0427aacb..945f9c744 100644
--- a/tests/e2e/chat/menu.spec.ts
+++ b/tests/e2e/chat/menu.spec.ts
@@ -29,3 +29,37 @@ test('mobile conversation menu stays attached to its trigger and inside the view
})
.toBe(true)
})
+
+test('deleting a conversation asks in a confirmation dialog', async ({
+ configuredChat: chat,
+ page
+}) => {
+ await chat.submit('Conversation to keep')
+ await expect(chat.assistantMessage()).toBeVisible()
+ await page.getByRole('button', { name: 'New chat', exact: true }).first().click()
+ await chat.submit('Conversation to delete')
+ await expect(chat.assistantMessage()).toBeVisible()
+ const actions = page.getByRole('button', { name: 'Conversation actions' })
+
+ await actions.click()
+ await page.getByRole('menuitem', { name: 'Delete' }).click()
+ const dialog = page.getByRole('alertdialog', { name: 'Delete this conversation permanently?' })
+ await expect(dialog).toContainText('“Conversation to delete” and its messages are removed.')
+ await dialog.getByRole('button', { name: 'Cancel' }).click()
+ await expect(dialog).toBeHidden()
+ await expect(chat.userMessage()).toContainText('Conversation to delete')
+
+ await actions.click()
+ await page.getByRole('menuitem', { name: 'Delete' }).click()
+ await dialog.getByRole('button', { name: 'Delete' }).click()
+ await expect(dialog).toBeHidden()
+ await expect(page.getByTestId('chat-message-user')).toHaveCount(0)
+
+ // The conversation is gone from storage, not only from the panel.
+ await page.reload()
+ await chat.chatTab.click()
+ await page.getByRole('button', { name: 'Conversation history', exact: true }).click()
+ await page.getByRole('button', { name: 'All documents', exact: true }).click()
+ await expect(page.getByRole('button', { name: /Conversation to keep/ })).toBeVisible()
+ await expect(page.getByRole('button', { name: /Conversation to delete/ })).toHaveCount(0)
+})
diff --git a/tests/e2e/chat/profiles.spec.ts b/tests/e2e/chat/profiles.spec.ts
index 4bb61979e..d18388cf3 100644
--- a/tests/e2e/chat/profiles.spec.ts
+++ b/tests/e2e/chat/profiles.spec.ts
@@ -1,3 +1,4 @@
+import { CATALOG_ONLY_MODEL } from '#tests/helpers/chat/catalog'
import { expect, test } from '#tests/helpers/chat/fixture'
test('Design profile selector exposes provider and capabilities', async ({
@@ -16,6 +17,8 @@ test('OpenRouter accepts a custom model ID from Settings', async ({ configuredCh
await chat.page.getByTestId('provider-settings-trigger').click()
await chat.page.locator('[data-model-id]').first().click()
await chat.page.getByLabel('Model ID').click()
+ // The catalog replaces the fallback list once it loads; pick only after it has settled.
+ await expect(chat.page.getByRole('option', { name: CATALOG_ONLY_MODEL })).toBeVisible()
await chat.page.getByRole('option', { name: 'Custom model…' }).click()
const input = chat.page.getByTestId('provider-settings-custom-model')
await input.fill(customModel)
diff --git a/tests/e2e/chat/reasoning.spec.ts b/tests/e2e/chat/reasoning.spec.ts
index 23e1618e2..8693043ae 100644
--- a/tests/e2e/chat/reasoning.spec.ts
+++ b/tests/e2e/chat/reasoning.spec.ts
@@ -19,9 +19,11 @@ test('expand while thinking auto-collapses but preserves manual opening', async
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
await trigger.click()
await trigger.click()
+ // Time the auto-close instead of sleeping past it.
+ await page.clock.install()
await finishReasoning(page)
await expect(chat.assistantMessage()).toContainText('Finished inspecting')
- await page.waitForTimeout(1200)
+ await page.clock.runFor(1500)
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
})
diff --git a/tests/e2e/chat/turns.spec.ts b/tests/e2e/chat/turns.spec.ts
index cef9f87f2..c4a53249f 100644
--- a/tests/e2e/chat/turns.spec.ts
+++ b/tests/e2e/chat/turns.spec.ts
@@ -21,12 +21,17 @@ test('reverting a reply undoes its edits, marks it, saves the chat, and tells th
await expect(chat.assistantMessage()).toContainText('Done')
expect(await cardCount(page)).toBe(1)
+ const content = chat.assistantMessage().locator('[data-slot="chat-reply-content"]')
await chat.assistantMessage().getByTestId('chat-revert-turn').click()
expect(await cardCount(page)).toBe(0)
+ await expect(content).toHaveAttribute('data-reverted', 'true')
+ await expect(content).toHaveCSS('opacity', '0.5')
// Restore redoes the edits, then reverting again leaves the reply marked.
await chat.assistantMessage().getByTestId('chat-restore-turn').click()
expect(await cardCount(page)).toBe(1)
+ await expect(content).toHaveAttribute('data-reverted', 'false')
+ await expect(content).toHaveCSS('opacity', '1')
await chat.assistantMessage().getByTestId('chat-revert-turn').click()
expect(await cardCount(page)).toBe(0)
diff --git a/tests/e2e/settings/credentials.spec.ts b/tests/e2e/settings/credentials.spec.ts
index 25f3e2e54..20bb3f23f 100644
--- a/tests/e2e/settings/credentials.spec.ts
+++ b/tests/e2e/settings/credentials.spec.ts
@@ -238,7 +238,8 @@ test('remembered browser credentials survive reload and clear centrally', async
})
test('browser credential preferences live in General, not the footer', async ({ page }) => {
- await page.goto('/')
+ await page.goto('/?test')
+ await new CanvasHelper(page).waitForInit()
await page.keyboard.press('ControlOrMeta+,')
const panel = page.getByTestId('settings-general-panel')
const remember = panel.getByRole('switch', { name: 'Remember API keys on this device' })
diff --git a/tests/helpers/chat/catalog.ts b/tests/helpers/chat/catalog.ts
new file mode 100644
index 000000000..d76cf3257
--- /dev/null
+++ b/tests/helpers/chat/catalog.ts
@@ -0,0 +1,26 @@
+import type { Page } from '@playwright/test'
+
+/** A model only the fixed catalog lists, so a test can tell when the catalog has loaded. */
+export const CATALOG_ONLY_MODEL = 'Catalog Test Model'
+
+const CATALOG = {
+ openrouter: {
+ models: {
+ 'test/catalog-model': {
+ name: CATALOG_ONLY_MODEL,
+ tool_call: true,
+ attachment: true,
+ release_date: '2026-01-01',
+ modalities: { output: ['text'] },
+ limit: { output: 16384 }
+ }
+ }
+ }
+}
+
+/** Serves a small fixed models.dev catalog, so model pickers never depend on the live one. */
+export async function routeModelCatalog(page: Page): Promise {
+ await page.route('https://models.dev/api.json', (route) =>
+ route.fulfill({ contentType: 'application/json', body: JSON.stringify(CATALOG) })
+ )
+}
diff --git a/tests/helpers/chat/fixture.ts b/tests/helpers/chat/fixture.ts
index 3fb965647..51a279955 100644
--- a/tests/helpers/chat/fixture.ts
+++ b/tests/helpers/chat/fixture.ts
@@ -1,8 +1,10 @@
import { test as base, expect } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
+import { routeModelCatalog } from '#tests/helpers/chat/catalog'
import { ChatHarness } from '#tests/helpers/chat/harness'
import { injectMockChatTransport } from '#tests/helpers/chat/transport'
+import { routeVisionModel } from '#tests/helpers/chat/vision'
interface ChatFixtures {
chat: ChatHarness
@@ -11,6 +13,8 @@ interface ChatFixtures {
export const test = base.extend({
chat: async ({ page }, use) => {
+ await routeModelCatalog(page)
+ await routeVisionModel(page)
const harness = new ChatHarness(page)
await harness.open()
const canvas = new CanvasHelper(page)
diff --git a/tests/helpers/chat/vision.ts b/tests/helpers/chat/vision.ts
new file mode 100644
index 000000000..76f5b3ad8
--- /dev/null
+++ b/tests/helpers/chat/vision.ts
@@ -0,0 +1,30 @@
+import type { Page } from '@playwright/test'
+
+/** What the stand-in Vision model reports for every attached image. */
+const VISION_FINDINGS = 'A centered portrait on a light background.'
+
+/**
+ * Answers the Vision model's request for attached images, which goes to OpenRouter directly rather
+ * than through the chat transport, so image messages never reach the network.
+ */
+export async function routeVisionModel(page: Page): Promise {
+ await page.route('https://openrouter.ai/api/v1/chat/completions', (route) =>
+ route.fulfill({
+ contentType: 'application/json',
+ body: JSON.stringify({
+ id: 'vision-test',
+ object: 'chat.completion',
+ created: 0,
+ model: 'test/vision',
+ choices: [
+ {
+ index: 0,
+ message: { role: 'assistant', content: VISION_FINDINGS },
+ finish_reason: 'stop'
+ }
+ ],
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }
+ })
+ })
+ )
+}