fix(settings): stabilize model editing and animate dialogs

This commit is contained in:
Danila Poyarkov 2026-09-10 19:49:36 +03:00
parent 6e74ad2a51
commit eb277dd2a9
8 changed files with 201 additions and 105 deletions

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { DialogClose } from 'reka-ui'
import { ref } from 'vue'
import { useI18n, useViewportKind } from '@open-pencil/vue'
@ -28,7 +29,13 @@ import AppTabsTrigger from '@/components/ui/tabs/AppTabsTrigger.vue'
const { isMobile } = useViewportKind()
const { settings, common } = useI18n()
const editingModel = ref(false)
function onSectionChange(section: string | number): void {
if (editingModel.value) return
settingsDialogSection.value = section as typeof settingsDialogSection.value
}
function onOpenChange(open: boolean): void {
if (!open && editingModel.value) return
settingsDialogOpen.value = open
}
</script>
@ -45,13 +52,15 @@ function onOpenChange(open: boolean): void {
:heading="settings.title"
:description="settings.description"
:close-label="common.close"
:show-close="!editingModel"
/>
<AppTabsRoot
v-model="settingsDialogSection"
:model-value="settingsDialogSection"
@update:model-value="onSectionChange"
:orientation="isMobile ? 'horizontal' : 'vertical'"
>
<AppTabsList :label="settings.title">
<AppTabsList :label="settings.title" :inert="editingModel || undefined">
<AppTabsTrigger value="general" data-test-id="settings-section-general">
<template #leading><icon-lucide-settings class="size-3.5" /></template>
{{ settings.general }}
@ -88,8 +97,9 @@ function onOpenChange(open: boolean): void {
<AppTabsContent value="ai" as-child>
<AppDialogBody>
<section class="flex h-full flex-col" data-test-id="settings-ai-panel">
<ModelsPanel />
<ChatSettingsSection />
<ModelsPanel v-model:editing="editingModel">
<ChatSettingsSection />
</ModelsPanel>
</section>
</AppDialogBody>
</AppTabsContent>
@ -121,7 +131,7 @@ function onOpenChange(open: boolean): void {
</AppTabsContent>
</AppTabsRoot>
<AppDialogFooter>
<AppDialogFooter v-if="!editingModel || settingsDialogSection !== 'ai'">
<DialogClose as-child>
<AppButton color="primary" variant="solid" data-test-id="app-settings-done">
{{ common.done }}

View file

@ -4,7 +4,6 @@ import { computed } from 'vue'
import { useI18n } from '@open-pencil/vue'
import { reasoningDisplay } from '@/app/ai/chat/preferences'
import SettingsGroup from '@/components/settings/layout/SettingsGroup.vue'
import SettingsSectionHeader from '@/components/settings/layout/SettingsSectionHeader.vue'
import AppSelect from '@/components/ui/select/AppSelect.vue'
@ -17,18 +16,11 @@ const options = computed(() => [
</script>
<template>
<section class="mt-4 flex flex-col gap-3">
<section class="mt-5 flex flex-col gap-3 border-t border-border pt-4">
<SettingsSectionHeader>{{ ai.chatSettings }}</SettingsSectionHeader>
<SettingsGroup>
<label class="flex flex-wrap items-center justify-between gap-4 px-3 py-2.5">
<span class="text-xs text-surface">{{ ai.reasoningDisplay }}</span>
<AppSelect
v-model="reasoningDisplay"
:label="ai.reasoningDisplay"
:options="options"
class="w-44"
/>
</label>
</SettingsGroup>
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-center gap-3">
<span class="text-[11px] text-surface">{{ ai.reasoningDisplay }}</span>
<AppSelect v-model="reasoningDisplay" :label="ai.reasoningDisplay" :options="options" />
</div>
</section>
</template>

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { templateRef } from '@vueuse/core'
import { nextTick, ref, onUnmounted } from 'vue'
import { useI18n } from '@open-pencil/vue'
@ -8,17 +9,41 @@ import ProfileEditor from '@/components/settings/models/ProfileEditor.vue'
import RoleAssignments from '@/components/settings/models/RoleAssignments.vue'
import AppButton from '@/components/ui/button/AppButton.vue'
import AppActionRow from '@/components/ui/list/AppActionRow.vue'
import { modelPanelTransition } from '@/theme/settings/models'
const { ai, collaboration, common } = useI18n()
const editing = ref(false)
const editing = defineModel<boolean>('editing', { default: false })
const editingProfileId = ref<string>()
const editorLeaving = ref(false)
const panel = templateRef<HTMLElement>('panel')
let returnFocus: HTMLElement | null = null
function captureFocus() {
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
}
async function restoreFocus() {
editorLeaving.value = false
await nextTick()
if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true })
returnFocus = null
}
async function focusEditor() {
await nextTick()
panel.value
?.querySelector<HTMLInputElement>('[data-test-id="settings-model-editor"] input')
?.focus({ preventScroll: true })
}
onUnmounted(() => {
editing.value = false
})
function addModel(): void {
captureFocus()
editingProfileId.value = undefined
editing.value = true
}
function editModel(profileId: string): void {
captureFocus()
editingProfileId.value = profileId
editing.value = true
}
@ -32,8 +57,8 @@ function statusLabel(connectionId: string, providerID: string): string {
}
function closeEditor(): void {
editorLeaving.value = true
editing.value = false
editingProfileId.value = undefined
void refreshStatuses()
}
@ -41,89 +66,104 @@ const { profiles, statusByConnection, refreshStatuses } = useModelSettings()
</script>
<template>
<ProfileEditor
v-if="editing"
:key="editingProfileId ?? 'new'"
:profile-id="editingProfileId"
@done="closeEditor"
@deleted="closeEditor"
/>
<div v-else class="scrollbar-thin flex min-h-0 flex-1 flex-col overflow-y-auto pr-1">
<section>
<div class="mb-2 flex items-center justify-between">
<div>
<h3 class="text-xs font-semibold text-surface">{{ ai.modelsTitle }}</h3>
<p class="text-[10px] text-muted">{{ ai.modelsDescription }}</p>
</div>
<AppButton
color="primary"
variant="solid"
data-test-id="settings-add-model"
@click="addModel"
>
<template #leading><icon-lucide-plus class="size-3" /></template>
{{ ai.addModel }}
</AppButton>
<div ref="panel" class="relative flex min-h-0 flex-1 flex-col">
<Transition
v-bind="modelPanelTransition"
@after-enter="focusEditor"
@after-leave="restoreFocus"
>
<div v-if="editing" class="absolute inset-0 flex min-h-0 flex-col">
<ProfileEditor
:key="editingProfileId ?? 'new'"
:profile-id="editingProfileId"
@done="closeEditor"
@deleted="closeEditor"
/>
</div>
</Transition>
<div class="flex flex-col gap-1.5" data-test-id="settings-model-list">
<AppActionRow
v-for="profile in profiles"
:key="profile.id"
:data-model-id="profile.id"
@click="editModel(profile.id)"
>
<template #leading>
<span class="flex size-8 items-center justify-center rounded bg-panel"
><icon-lucide-bot class="size-4"
/></span>
</template>
{{ profile.name }}
<template #description>
{{ profile.providerName
}}<span v-if="profile.modelName"> · {{ profile.modelName }}</span>
</template>
<template #trailing>
<span
class="mr-1 flex items-center gap-1 text-[9px] text-muted"
:data-state="
statusByConnection[profile.connectionId] === 'configured' ? 'configured' : 'missing'
"
>
<div
v-show="!editing && !editorLeaving"
class="scrollbar-thin min-h-0 flex-1 overflow-y-auto pr-1"
>
<section>
<div class="mb-2 flex items-center justify-between">
<div>
<h3 class="text-xs font-semibold text-surface">{{ ai.modelsTitle }}</h3>
<p class="text-[10px] text-muted">{{ ai.modelsDescription }}</p>
</div>
<AppButton
color="primary"
variant="solid"
data-test-id="settings-add-model"
@click="addModel"
>
<template #leading><icon-lucide-plus class="size-3" /></template>
{{ ai.addModel }}
</AppButton>
</div>
<div class="flex flex-col gap-1.5" data-test-id="settings-model-list">
<AppActionRow
v-for="profile in profiles"
:key="profile.id"
:data-model-id="profile.id"
@click="editModel(profile.id)"
>
<template #leading>
<span class="flex size-8 items-center justify-center rounded bg-panel"
><icon-lucide-bot class="size-4"
/></span>
</template>
{{ profile.name }}
<template #description>
{{ profile.providerName
}}<span v-if="profile.modelName"> · {{ profile.modelName }}</span>
</template>
<template #trailing>
<span
class="size-1.5 rounded-full bg-muted data-[state=configured]:bg-[var(--color-success)]"
class="mr-1 flex items-center gap-1 text-[9px] text-muted"
:data-state="
statusByConnection[profile.connectionId] === 'configured'
? 'configured'
: 'missing'
"
/>
{{ statusLabel(profile.connectionId, profile.providerID) }}
</span>
<span
v-for="capability in profile.capabilities"
:key="capability"
class="rounded bg-panel px-1.5 py-0.5 text-[9px] text-muted"
>
{{
capability === 'tools'
? ai.modelCapabilityToolsShort
: ai.modelCapabilityVisionShort
}}
</span>
<icon-lucide-chevron-right class="size-3.5 shrink-0 text-muted" />
</template>
</AppActionRow>
</div>
</section>
>
<span
class="size-1.5 rounded-full bg-muted data-[state=configured]:bg-[var(--color-success)]"
:data-state="
statusByConnection[profile.connectionId] === 'configured'
? 'configured'
: 'missing'
"
/>
{{ statusLabel(profile.connectionId, profile.providerID) }}
</span>
<span
v-for="capability in profile.capabilities"
:key="capability"
class="rounded bg-panel px-1.5 py-0.5 text-[9px] text-muted"
>
{{
capability === 'tools'
? ai.modelCapabilityToolsShort
: ai.modelCapabilityVisionShort
}}
</span>
<icon-lucide-chevron-right class="size-3.5 shrink-0 text-muted" />
</template>
</AppActionRow>
</div>
</section>
<section class="mt-5 border-t border-border pt-4">
<div class="mb-3">
<h3 class="text-xs font-semibold text-surface">{{ ai.modelAssignments }}</h3>
<p class="text-[10px] text-muted">{{ ai.modelAssignmentsDescription }}</p>
</div>
<RoleAssignments />
</section>
<section class="mt-5 border-t border-border pt-4">
<div class="mb-3">
<h3 class="text-xs font-semibold text-surface">{{ ai.modelAssignments }}</h3>
<p class="text-[10px] text-muted">{{ ai.modelAssignmentsDescription }}</p>
</div>
<RoleAssignments />
</section>
<slot />
</div>
</div>
</template>

View file

@ -12,7 +12,6 @@ import ProviderSettingsField from '@/components/settings/provider/ProviderSettin
import ProviderSettingsInput from '@/components/settings/provider/ProviderSettingsInput.vue'
import ProviderSettingsKeyField from '@/components/settings/provider/ProviderSettingsKeyField.vue'
import AppButton from '@/components/ui/button/AppButton.vue'
import IconButton from '@/components/ui/button/IconButton.vue'
import { AppConfirmationDialog } from '@/components/ui/dialog'
import AppInput from '@/components/ui/input/AppInput.vue'
import AppCombobox from '@/components/ui/select/AppCombobox.vue'
@ -71,10 +70,7 @@ async function remove() {
<template>
<div class="flex min-h-0 flex-1 flex-col" data-test-id="settings-model-editor">
<div class="flex items-center gap-2 border-b border-border pb-3">
<IconButton :label="common.back" @click="emit('done')">
<icon-lucide-arrow-left class="size-3.5" />
</IconButton>
<div class="flex shrink-0 items-center gap-2 border-b border-border pb-3">
<div>
<h3 class="text-xs font-semibold text-surface">
{{ profileId ? ai.editModel : ai.addModel }}

View file

@ -1,8 +1,13 @@
const dialogMotion =
'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:duration-180 data-[state=open]:ease-out data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:duration-120 data-[state=closed]:ease-in motion-reduce:animate-none'
export default {
slots: {
overlay: 'fixed inset-0 z-40 bg-black/50',
content:
'fixed top-1/2 left-1/2 z-50 flex max-h-[min(90vh,48rem)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl bg-panel shadow-[0_8px_30px_rgb(0_0_0/0.4)] outline-none',
overlay: `fixed inset-0 z-40 bg-black/50 ${dialogMotion}`,
content: [
'fixed top-1/2 left-1/2 z-50 flex max-h-[min(90vh,48rem)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl bg-panel shadow-[0_8px_30px_rgb(0_0_0/0.4)] outline-none data-[state=open]:zoom-in-98 data-[state=closed]:zoom-out-98',
dialogMotion
],
header: 'flex shrink-0 items-start justify-between gap-3 border-b border-border px-4 py-3',
heading: 'min-w-0',
title: 'text-sm font-semibold text-surface data-[visually-hidden=true]:sr-only',

View file

@ -0,0 +1,7 @@
export const modelPanelTransition = {
enterActiveClass:
'transition-[opacity,translate] duration-150 ease-out motion-reduce:transition-none',
enterFromClass: 'opacity-0 translate-x-1 motion-reduce:translate-x-0',
leaveActiveClass: 'transition-opacity duration-100 ease-in motion-reduce:transition-none',
leaveToClass: 'opacity-0'
} as const

View file

@ -210,7 +210,10 @@ test('remembered browser credentials survive reload and clear centrally', async
await page.getByTestId('settings-section-ai').click()
await page.locator('[data-model-id]').first().click()
await page.getByTestId('provider-settings-clear-key').click()
await page.getByRole('button', { name: 'Back' }).click()
await page
.getByTestId('settings-model-editor')
.getByRole('button', { name: 'Cancel', exact: true })
.click()
await page.getByTestId('settings-section-general').click()
await remember.click()
await page.getByTestId('app-settings-done').click()

View file

@ -0,0 +1,43 @@
import { expect, test } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
test('model editing keeps the Settings shell stable and isolates the form', async ({ page }) => {
await page.goto('/?test')
await new CanvasHelper(page).waitForInit()
await page.getByTestId('app-settings-trigger').click()
await page.getByTestId('settings-section-ai').click()
const dialog = page.getByTestId('app-settings-dialog')
await expect(dialog).toBeVisible()
await dialog.evaluate(async (element) => {
await Promise.all(element.getAnimations().map((animation) => animation.finished))
})
const before = await dialog.boundingBox()
await page.getByTestId('settings-add-model').click()
const editor = page.getByTestId('settings-model-editor')
await expect(editor).toBeVisible()
await expect(dialog.getByText('Chat', { exact: true })).not.toBeVisible()
await expect(dialog.getByRole('button', { name: 'Back', exact: true })).toHaveCount(0)
await expect(page.getByTestId('app-settings-done')).toHaveCount(0)
expect(await dialog.boundingBox()).toEqual(before)
// Accidental dismissal must not throw away an in-progress profile.
await page.keyboard.press('Escape')
await expect(editor).toBeVisible()
await editor.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(editor).toHaveCount(0)
await expect(page.getByTestId('app-settings-done')).toBeVisible()
expect(await dialog.boundingBox()).toEqual(before)
})
test('page rows keep the same compact height while renaming', async ({ page }) => {
await page.goto('/?test')
await new CanvasHelper(page).waitForInit()
const row = page.getByTestId('pages-row').first()
await expect(row).toHaveCSS('height', '24px')
await row.getByRole('button').dblclick()
await expect(page.getByTestId('pages-item-input')).toBeVisible()
await expect(row).toHaveCSS('height', '24px')
await page.getByTestId('pages-item-input').press('Escape')
await expect(row).toHaveCSS('height', '24px')
})