fix(settings): stabilize model editing and animate dialogs
This commit is contained in:
parent
6e74ad2a51
commit
eb277dd2a9
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
7
src/theme/settings/models.ts
Normal file
7
src/theme/settings/models.ts
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
43
tests/e2e/settings/model-editor.spec.ts
Normal file
43
tests/e2e/settings/model-editor.spec.ts
Normal 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')
|
||||
})
|
||||
Loading…
Reference in a new issue