fix(ai): polish chat history and stabilize chat tests (#923)
* fix(ai): confirm deleting a conversation in the standard dialog Deleting a conversation asked inside the chat panel's header, unlike every other destructive confirmation, which uses AppConfirmationDialog. It now opens that dialog, which names the conversation; Cancel keeps it and Delete removes it. * feat(ai): dim a reverted reply's content A reverted reply still showed its tool cards with their success marks and its text as if the edits were there; only the line below said otherwise. Its content is now dimmed, while the Changes reverted or Restore changes line and the other actions stay at full strength. * ci: typecheck the test suites in Code quality bun run check runs check:test-types, but PR CI did not, so tests that no longer type-checked could merge and break bun run check on master, as the grid fixtures from #866 did. It runs as a step of Code quality, which already builds the package declarations it needs; the check takes about two seconds, less than a separate job's setup. * test(ai): time the reasoning auto-close with the fake clock The test slept 1200 ms of real time past the 1000 ms auto-close to show that a manually opened block stays open, which was timing-dependent under load. It now advances Playwright's clock past the timer. With manual opening ignored, it fails. * test(ai): skip file identity on Playwright's bundled Chromium on macOS There, reading a FileSystemFileHandle back from IndexedDB closes the page before any app code runs, so the test always failed locally on a Mac. Installed Google Chrome on the same machine reads and compares the handle correctly, and the app's resolveFile returns one ID for twelve concurrent calls there. The skip is limited to the bundled build on macOS. * fix(settings): keep a model picker open while the editor slides in The models panel focused the editor's first field after its enter transition, even when focus had already moved into the editor, closing a picker opened during the animation. It now leaves focus alone once it has moved since the editor opened. * test: serve a fixed model catalog and wait for the editor before Settings shortcuts Chat tests fetched the live models.dev catalog, so the model list changed under them; they now get a small fixed one. The credentials test pressed the Settings shortcut before the editor was ready. * fix(settings): move drill-in focus with Reka's FocusScope The drill-in focused the detail's first field after its slide-in ended, found by querying the DOM, and restored focus by hand, so a picker opened during the slide closed. FocusScope now focuses the detail as it mounts and returns focus to what opened it. A caller can prevent open-auto-focus to focus its own field, which the models panel does for the profile name through the editor's exposed focus(). * test(ai): answer the Vision model's request in chat tests A message with images is analyzed by the Vision model, which calls OpenRouter directly rather than the mocked chat transport. With the test key the request failed, and since #916 a failure before dispatch takes the message back, so the WebKit history test never got a reply. The chat fixture now answers that request with fixed findings. * test(ai): check that deletion persists and reverted replies dim The delete test passed even if the conversation stayed in storage, since the panel clears its messages before the store removes it; it now reloads and checks the history. The revert test checked only data-reverted; it now checks the rendered opacity too.
This commit is contained in:
parent
2f79278df1
commit
af18eec514
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@
|
|||
"saveChatTitle": "保存",
|
||||
"cancelChatAction": "キャンセル",
|
||||
"deleteChatConfirmation": "このチャットを完全に削除しますか?",
|
||||
"deleteChatDescription": "「{title}」とそのメッセージは削除されます。元に戻すことはできません。",
|
||||
"chatDocumentHint": "続けるにはチャットのドキュメントを開いてください。",
|
||||
"chatReadOnly": "このチャットは別のドキュメントに属しています。続けるにはそのドキュメントを開いてください。",
|
||||
"chatAgentReadOnly": "エージェントとの会話は保存されていますが、ここではまだセッションを再開できません。新しいチャットを開始してください。",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@
|
|||
"saveChatTitle": "Сохранить",
|
||||
"cancelChatAction": "Отмена",
|
||||
"deleteChatConfirmation": "Удалить этот чат навсегда?",
|
||||
"deleteChatDescription": "«{title}» и её сообщения будут удалены. Это действие нельзя отменить.",
|
||||
"chatDocumentHint": "Откройте документ чата, чтобы продолжить.",
|
||||
"chatReadOnly": "Этот чат относится к другому документу. Откройте его, чтобы продолжить.",
|
||||
"chatAgentReadOnly": "Переписка с агентом сохранена, но восстановление его сессии пока недоступно. Начните новый чат.",
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@
|
|||
"saveChatTitle": "保存",
|
||||
"cancelChatAction": "取消",
|
||||
"deleteChatConfirmation": "永久删除此聊天?",
|
||||
"deleteChatDescription": "“{title}”及其消息将被删除,且无法撤销。",
|
||||
"chatDocumentHint": "打开聊天所属的文档以继续。",
|
||||
"chatReadOnly": "此聊天属于另一个文档。打开该文档以继续。",
|
||||
"chatAgentReadOnly": "与代理的对话已保存,但暂时无法在此恢复会话。请新建聊天。",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
}}</AppButton>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="confirming" :class="styles.form()" @keydown.esc.stop="confirming = false">
|
||||
<p :class="styles.confirmation()">{{ ai.deleteChatConfirmation }}</p>
|
||||
<div :class="styles.actions()">
|
||||
<AppButton size="xs" @click="confirming = false">{{ ai.cancelChatAction }}</AppButton
|
||||
><AppButton size="xs" color="error" :disabled="disabled" @click="remove">{{
|
||||
ai.deleteChat
|
||||
}}</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
<AppConfirmationDialog
|
||||
v-model:open="confirming"
|
||||
:heading="ai.deleteChatConfirmation"
|
||||
:description="ai.deleteChatDescription({ title: selected?.title ?? '' })"
|
||||
:cancel-label="ai.cancelChatAction"
|
||||
:confirm-label="ai.deleteChat"
|
||||
tone="danger"
|
||||
:confirm-disabled="disabled"
|
||||
@confirm="remove"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ async function copyResponse(): Promise<void> {
|
|||
|
||||
// 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%]' : ''"
|
||||
>
|
||||
<template v-if="message.role === 'assistant'">
|
||||
<template v-for="group in groups" :key="groupKey(group)">
|
||||
<ToolCallGroup
|
||||
v-if="group.kind === 'tools'"
|
||||
:parts="group.parts.map(({ part }) => part)"
|
||||
/>
|
||||
<!-- A reverted reply's edits are gone; its content stays, dimmed, under its actions. -->
|
||||
<div
|
||||
class="space-y-2 transition-opacity motion-reduce:transition-none data-[reverted=true]:opacity-50"
|
||||
data-slot="chat-reply-content"
|
||||
:data-reverted="reverted"
|
||||
>
|
||||
<template v-for="group in groups" :key="groupKey(group)">
|
||||
<ToolCallGroup
|
||||
v-if="group.kind === 'tools'"
|
||||
:parts="group.parts.map(({ part }) => part)"
|
||||
/>
|
||||
|
||||
<!-- Reasoning -->
|
||||
<ReasoningBlock
|
||||
v-else-if="isReasoningUIPart(group.part) && group.part.text"
|
||||
:text="group.part.text"
|
||||
:display="reasoningDisplay"
|
||||
:streaming="group.part.state === 'streaming'"
|
||||
:thinking-label="ai.thinking"
|
||||
:reasoning-label="ai.reasoning"
|
||||
:duration-label="(seconds) => ai.thoughtFor({ seconds })"
|
||||
/>
|
||||
<!-- Reasoning -->
|
||||
<ReasoningBlock
|
||||
v-else-if="isReasoningUIPart(group.part) && group.part.text"
|
||||
:text="group.part.text"
|
||||
:display="reasoningDisplay"
|
||||
:streaming="group.part.state === 'streaming'"
|
||||
:thinking-label="ai.thinking"
|
||||
:reasoning-label="ai.reasoning"
|
||||
:duration-label="(seconds) => ai.thoughtFor({ seconds })"
|
||||
/>
|
||||
|
||||
<!-- Text -->
|
||||
<div
|
||||
v-else-if="isTextUIPart(group.part) && group.part.text"
|
||||
data-test-id="chat-text-bubble"
|
||||
class="group/response relative rounded-xl rounded-tl-md bg-hover px-3 py-2 text-xs leading-relaxed text-surface"
|
||||
>
|
||||
<ChatMarkdown :content="group.part.text" :mode="markdownMode" />
|
||||
<IconButton
|
||||
v-if="
|
||||
group.index === firstAssistantTextPartIndex && assistantText && clipboardSupported
|
||||
"
|
||||
:label="copied ? ai.responseCopied : ai.copyResponse"
|
||||
size="xs"
|
||||
data-slot="chat-copy-response"
|
||||
class="absolute right-1 bottom-1 opacity-0 focus-visible:opacity-100 group-hover/response:opacity-100"
|
||||
@click="copyResponse"
|
||||
<!-- Text -->
|
||||
<div
|
||||
v-else-if="isTextUIPart(group.part) && group.part.text"
|
||||
data-test-id="chat-text-bubble"
|
||||
class="group/response relative rounded-xl rounded-tl-md bg-hover px-3 py-2 text-xs leading-relaxed text-surface"
|
||||
>
|
||||
<icon-lucide-check v-if="copied" class="size-3 text-green-400" />
|
||||
<icon-lucide-copy v-else class="size-3" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</template>
|
||||
<ChatMarkdown :content="group.part.text" :mode="markdownMode" />
|
||||
<IconButton
|
||||
v-if="
|
||||
group.index === firstAssistantTextPartIndex && assistantText && clipboardSupported
|
||||
"
|
||||
:label="copied ? ai.responseCopied : ai.copyResponse"
|
||||
size="xs"
|
||||
data-slot="chat-copy-response"
|
||||
class="absolute right-1 bottom-1 opacity-0 focus-visible:opacity-100 group-hover/response:opacity-100"
|
||||
@click="copyResponse"
|
||||
>
|
||||
<icon-lucide-check v-if="copied" class="size-3 text-green-400" />
|
||||
<icon-lucide-copy v-else class="size-3" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<ChatTurnActions
|
||||
v-if="!streaming"
|
||||
:message-id="message.id"
|
||||
:can-regenerate="canRegenerate"
|
||||
:reverted="revertOf(message) !== null"
|
||||
:reverted="reverted"
|
||||
@regenerate="emit('regenerate')"
|
||||
@revert="emit('revert')"
|
||||
@restore="emit('restore')"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
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<boolean>('editing', { default: false })
|
||||
const editingProfileId = ref<string>()
|
||||
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()
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<PanelDrillIn :open="editing" :back="common.back" :parent="ai.modelsTitle" @back="closeEditor">
|
||||
<PanelDrillIn
|
||||
:open="editing"
|
||||
:back="common.back"
|
||||
:parent="ai.modelsTitle"
|
||||
@back="closeEditor"
|
||||
@open-auto-focus="focusEditor"
|
||||
>
|
||||
<template #detail>
|
||||
<ProfileEditor
|
||||
ref="editor"
|
||||
:key="editingProfileId ?? 'new'"
|
||||
:profile-id="editingProfileId"
|
||||
@done="closeEditor"
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ const { profileId } = defineProps<{ profileId?: string }>()
|
|||
const emit = defineEmits<{ done: []; deleted: [] }>()
|
||||
const { ai, common, credentials, settings } = useI18n()
|
||||
const formElement = useTemplateRef<HTMLFormElement>('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')"
|
||||
>
|
||||
<AppInput
|
||||
ref="nameInput"
|
||||
v-bind="control"
|
||||
v-model="draft.name"
|
||||
:aria-label="ai.modelName"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
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<typeof drillInTheme>
|
||||
}>()
|
||||
const emit = defineEmits<{ back: [] }>()
|
||||
const emit = defineEmits<{ back: []; openAutoFocus: [event: Event] }>()
|
||||
|
||||
const styles = tv(drillInTheme)()
|
||||
const detail = useTemplateRef<HTMLElement>('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<HTMLElement>(FIELD) ??
|
||||
detail.value?.querySelector<HTMLElement>('[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
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="styles.root({ class: ui?.root })">
|
||||
<Transition
|
||||
v-bind="drillInTransition"
|
||||
@after-enter="focusDetail"
|
||||
@before-leave="uncover"
|
||||
@after-leave="restoreFocus"
|
||||
>
|
||||
<div
|
||||
<Transition v-bind="drillInTransition" @after-enter="covered = true" @before-leave="uncover">
|
||||
<FocusScope
|
||||
v-if="open"
|
||||
ref="detail"
|
||||
data-slot="detail"
|
||||
:class="styles.detail({ class: ui?.detail })"
|
||||
@mount-auto-focus="emit('openAutoFocus', $event)"
|
||||
>
|
||||
<div data-slot="header" :class="styles.header({ class: ui?.header })">
|
||||
<AppButton variant="ghost" size="sm" data-slot="back" @click="emit('back')">
|
||||
|
|
@ -89,7 +58,7 @@ async function restoreFocus() {
|
|||
<div data-slot="body" :class="styles.body({ class: ui?.body })">
|
||||
<slot name="detail" />
|
||||
</div>
|
||||
</div>
|
||||
</FocusScope>
|
||||
</Transition>
|
||||
<div v-show="!covered" :inert="open" data-slot="base" :class="styles.base({ class: ui?.base })">
|
||||
<slot />
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
26
tests/helpers/chat/catalog.ts
Normal file
26
tests/helpers/chat/catalog.ts
Normal file
|
|
@ -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<void> {
|
||||
await page.route('https://models.dev/api.json', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify(CATALOG) })
|
||||
)
|
||||
}
|
||||
|
|
@ -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<ChatFixtures>({
|
||||
chat: async ({ page }, use) => {
|
||||
await routeModelCatalog(page)
|
||||
await routeVisionModel(page)
|
||||
const harness = new ChatHarness(page)
|
||||
await harness.open()
|
||||
const canvas = new CanvasHelper(page)
|
||||
|
|
|
|||
30
tests/helpers/chat/vision.ts
Normal file
30
tests/helpers/chat/vision.ts
Normal file
|
|
@ -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<void> {
|
||||
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 }
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue