fix(ui): standardize scrollable areas (#2399)

This commit is contained in:
Lovehsigure_520
2026-08-29 22:34:24 +08:00
committed by GitHub
parent 484a2b7b19
commit da2ac8bbcc
34 changed files with 1387 additions and 479 deletions
@@ -30,7 +30,7 @@ async function extractColorsFromModel() {
<div flex class="relative h-full flex-col-reverse md:flex-row">
<ModelSettings
ref="modelSettingsRef"
settings-class="w-100% md:w-40% lg:w-40% xl:w-25% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
settings-class="w-100% md:w-40% lg:w-40% xl:w-25% 2xl:w-30% h-fit sm:max-h-80dvh relative"
live-2d-scene-class="absolute max-h-[calc(100dvh-100px-56px)] w-full h-full"
vrm-scene-class="absolute max-h-[calc(100dvh-100px-56px)] w-full h-full"
:palette="palette" @extract-colors-from-model="extractColorsFromModel"
@@ -6,7 +6,7 @@ import { createContext } from '@moeru/eventa/adapters/electron/renderer'
import { errorMessageFrom } from '@moeru/std'
import { artistryTestComfyUIConnection, isStageTamagotchi } from '@proj-airi/stage-shared'
import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
import { Button, FieldInput, GhostButton } from '@proj-airi/ui'
import { Button, FieldInput, GhostButton, ScrollableArea } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -493,34 +493,36 @@ function copyToClipboard(text: string) {
{{ t('settings.pages.providers.provider.comfyui.settings.upload.select_fields') }}
</div>
<div class="max-h-80 flex flex-col gap-2 overflow-y-auto">
<div
v-for="node in parsedWorkflow.nodes"
:key="node.id"
class="border border-neutral-200 rounded-lg p-3 dark:border-neutral-700"
>
<div class="mb-1 text-sm text-neutral-700 font-medium dark:text-neutral-300">
{{ node.title }}
<span class="ml-1 text-xs text-neutral-400">({{ node.type }})</span>
</div>
<div class="flex flex-col gap-1 pl-3">
<label
v-for="(val, field) in node.inputs"
:key="String(field)"
class="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-xs hover:bg-neutral-50 dark:hover:bg-neutral-800"
>
<input
type="checkbox"
class="accent-indigo-500"
:checked="isFieldSelected(node.title, String(field))"
@change="toggleField(node.title, String(field))"
<ScrollableArea :class="['max-h-80']">
<div :class="['flex flex-col gap-2']">
<div
v-for="node in parsedWorkflow.nodes"
:key="node.id"
class="border border-neutral-200 rounded-lg p-3 dark:border-neutral-700"
>
<div class="mb-1 text-sm text-neutral-700 font-medium dark:text-neutral-300">
{{ node.title }}
<span class="ml-1 text-xs text-neutral-400">({{ node.type }})</span>
</div>
<div class="flex flex-col gap-1 pl-3">
<label
v-for="(val, field) in node.inputs"
:key="String(field)"
class="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-xs hover:bg-neutral-50 dark:hover:bg-neutral-800"
>
<span class="text-neutral-600 font-mono dark:text-neutral-400">{{ field }}</span>
<span class="truncate text-neutral-400 dark:text-neutral-500">= {{ formatValue(val) }}</span>
</label>
<input
type="checkbox"
class="accent-indigo-500"
:checked="isFieldSelected(node.title, String(field))"
@change="toggleField(node.title, String(field))"
>
<span class="text-neutral-600 font-mono dark:text-neutral-400">{{ field }}</span>
<span class="truncate text-neutral-400 dark:text-neutral-500">= {{ formatValue(val) }}</span>
</label>
</div>
</div>
</div>
</div>
</ScrollableArea>
<div class="mt-2 flex items-center justify-between">
<span class="text-xs text-neutral-400">{{ t('settings.pages.providers.provider.comfyui.settings.upload.fields_exposed', { count: totalExposed }) }}</span>
@@ -1,11 +1,13 @@
<script lang="ts" setup>
import { ScrollableArea } from '@proj-airi/ui'
</script>
<template>
<div
bg="neutral-100 dark:neutral-800"
overflow-y-scroll
<ScrollableArea
:class="[
'bg-neutral-100 dark:bg-neutral-800',
]"
>
<slot />
</div>
</ScrollableArea>
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ScrollableArea } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { useJournalPreviewStore } from '../../../stores/journal-preview'
@@ -49,12 +50,16 @@ const { closePreview, downloadImage } = store
</div>
<!-- Content -->
<div v-if="previewModal.type === 'text'" class="max-h-[60vh] overflow-y-auto px-4 py-3">
<ScrollableArea
v-if="previewModal.type === 'text'"
:class="['max-h-[60vh]']"
:viewport-class="['px-4 py-3']"
>
<MarkdownRenderer
:content="previewModal.content"
class="max-w-none prose prose-sm dark:prose-invert"
/>
</div>
</ScrollableArea>
<div v-else class="flex items-center justify-center p-2">
<img :src="previewModal.content" class="max-h-[60vh] w-auto rounded-lg object-contain">
</div>
@@ -0,0 +1,95 @@
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-vue'
import { defineComponent, shallowRef } from 'vue'
import ChatHistoryScrollContainer from './chat-history-scroll-container.vue'
describe('chat history scroll container', () => {
// ROOT CAUSE:
//
// Reka mounts the desktop scrollbar only while the viewport scrolls, but it
// appears and disappears without the overlay fade used by the reference UI.
// We add the animation only to desktop chat so other scroll areas keep their
// existing presentation.
// Reference: https://kingsora.github.io/OverlayScrollbars/example/vue/
it('shows the desktop scrollbar only while the message viewport scrolls', async () => {
const container = shallowRef<InstanceType<typeof ChatHistoryScrollContainer>>()
const TestHost = defineComponent({
components: { ChatHistoryScrollContainer },
setup: () => ({ container }),
template: `
<ChatHistoryScrollContainer
ref="container"
variant="desktop"
style="height: 120px; width: 240px"
>
<div style="height: 240px">Long desktop history</div>
</ChatHistoryScrollContainer>
`,
})
const screen = await render(TestHost)
const viewport = screen.container.querySelector<HTMLElement>('.chat-history-list')
expect(viewport?.matches('[data-reka-scroll-area-viewport]')).toBe(true)
await new Promise(resolve => setTimeout(resolve, 150))
expect(screen.container.querySelector('.scrollable-area-scrollbar--vertical')).toBeNull()
if (!viewport)
throw new Error('Expected a desktop chat history viewport.')
viewport.scrollTop = 40
viewport.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => {
expect(screen.container.querySelector('.scrollable-area-scrollbar--vertical')).not.toBeNull()
})
const visibleScrollbar = screen.container.querySelector<HTMLElement>('.scrollable-area-scrollbar--vertical')
if (!visibleScrollbar)
throw new Error('Expected the desktop chat scrollbar while scrolling.')
expect(visibleScrollbar.dataset.state).toBe('visible')
expect(getComputedStyle(visibleScrollbar).animationName).toContain('chat-history-scrollbar-fade-in')
await vi.waitFor(() => {
const hidingScrollbar = screen.container.querySelector<HTMLElement>('.scrollable-area-scrollbar--vertical')
expect(hidingScrollbar?.dataset.state).toBe('hidden')
expect(getComputedStyle(hidingScrollbar!).animationName).toContain('chat-history-scrollbar-fade-out')
}, { interval: 20, timeout: 1200 })
await vi.waitFor(() => {
expect(screen.container.querySelector('.scrollable-area-scrollbar--vertical')).toBeNull()
}, { timeout: 1200 })
expect(container.value?.viewport).toBe(viewport)
expect(screen.container.querySelectorAll('.chat-history-list')).toHaveLength(1)
})
it('exposes a native overflow viewport for mobile chat', async () => {
const container = shallowRef<InstanceType<typeof ChatHistoryScrollContainer>>()
const TestHost = defineComponent({
components: { ChatHistoryScrollContainer },
setup: () => ({ container }),
template: `
<ChatHistoryScrollContainer
ref="container"
variant="mobile"
style="height: 120px; width: 240px"
>
<div style="height: 240px">Long mobile history</div>
</ChatHistoryScrollContainer>
`,
})
const screen = await render(TestHost)
const viewport = screen.container.querySelector<HTMLElement>('.chat-history-list')
expect(viewport).not.toBeNull()
if (!viewport)
throw new Error('Expected a mobile chat history viewport.')
expect(viewport.matches('[data-reka-scroll-area-viewport]')).toBe(false)
expect(screen.container.querySelector('.scrollable-area-scrollbar--vertical')).toBeNull()
expect(getComputedStyle(viewport).overflowY).toBe('auto')
expect(container.value?.viewport).toBe(viewport)
})
})
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { ScrollableArea } from '@proj-airi/ui'
import { computed, useTemplateRef } from 'vue'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<{
variant: 'desktop' | 'mobile'
}>()
const desktopAreaRef = useTemplateRef<InstanceType<typeof ScrollableArea>>('desktop-area')
const mobileViewportRef = useTemplateRef<HTMLElement>('mobile-viewport')
const viewport = computed<HTMLElement | null>(() => props.variant === 'desktop'
? desktopAreaRef.value?.viewport ?? null
: mobileViewportRef.value)
defineExpose({
viewport,
})
</script>
<template>
<ScrollableArea
v-if="props.variant === 'desktop'"
ref="desktop-area"
v-bind="$attrs"
type="scroll"
:viewport-class="[
'chat-history-list',
]"
:class="[
'chat-history-scroll-area h-full w-full',
]"
>
<slot />
</ScrollableArea>
<div
v-else
ref="mobile-viewport"
v-bind="$attrs"
:class="[
'chat-history-list chat-history-list--mobile',
'relative h-full w-full overflow-y-auto rounded-xl',
'<sm:px-2 <sm:py-2',
]"
>
<slot />
</div>
</template>
<style scoped>
.chat-history-scroll-area {
width: 100%;
height: 100%;
}
.chat-history-scroll-area :deep(.chat-history-list) {
position: relative;
width: 100%;
height: 100%;
border-radius: 0.75rem;
}
.chat-history-scroll-area :deep(.scrollable-area-scrollbar--vertical) {
will-change: opacity;
}
.chat-history-scroll-area :deep(.scrollable-area-scrollbar--vertical[data-state='visible']) {
animation: chat-history-scrollbar-fade-in 150ms ease-out both;
}
.chat-history-scroll-area :deep(.scrollable-area-scrollbar--vertical[data-state='hidden']) {
animation: chat-history-scrollbar-fade-out 180ms ease-in both;
}
@keyframes chat-history-scrollbar-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes chat-history-scrollbar-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.chat-history-scroll-area :deep(.scrollable-area-scrollbar--vertical) {
animation-duration: 1ms;
}
}
.chat-history-list--mobile {
position: relative;
width: 100%;
height: 100%;
overflow-y: auto;
border-radius: 0.75rem;
}
.chat-history-list--mobile :deep(.chat-message-item-container) {
--chat-top-fade-transparent-stop: -1px;
--chat-top-fade-opaque-stop: 0px;
-webkit-mask-image: linear-gradient(
to bottom,
transparent var(--chat-top-fade-transparent-stop),
black var(--chat-top-fade-opaque-stop)
);
mask-image: linear-gradient(
to bottom,
transparent var(--chat-top-fade-transparent-stop),
black var(--chat-top-fade-opaque-stop)
);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
}
</style>
@@ -19,6 +19,81 @@ function createEnglishI18n() {
}
describe('chat history', () => {
// ROOT CAUSE:
//
// The desktop chat needs the styled Reka viewport, but forcing its track to
// stay mounted leaves an inert scrollbar visible when short content cannot scroll.
// Reka's automatic visibility must own the track without changing the viewport.
it('uses a Reka viewport without showing a scrollbar for a short desktop chat', async () => {
const screen = await render(ChatHistory, {
props: {
messages: [{ id: 'user-1', role: 'user', content: 'Hello' }],
style: 'height: 240px; width: 320px; overflow-y: auto;',
},
global: {
plugins: [createEnglishI18n()],
},
})
await vi.waitFor(() => {
expect(screen.container.querySelector('.chat-history-list')).not.toBeNull()
})
const history = screen.container.querySelector<HTMLElement>('.chat-history-list')
expect(history).not.toBeNull()
if (!history)
throw new Error('Expected a chat history viewport.')
const track = screen.container.querySelector<HTMLElement>('.scrollable-area-scrollbar--vertical')
expect(history.matches('[data-reka-scroll-area-viewport]')).toBe(true)
expect(track === null || track.dataset.state === 'hidden').toBe(true)
})
it('virtualizes long desktop history inside one Reka viewport', async () => {
const messages: ChatHistoryItem[] = Array.from({ length: 100 }, (_, index) => ({
id: `desktop-user-${index}`,
role: 'user',
content: `Desktop message ${index} `.repeat(index % 6 + 1),
createdAt: index,
}))
const screen = await render(ChatHistory, {
props: {
messages,
style: 'height: 240px; width: 320px;',
},
global: {
plugins: [createEnglishI18n()],
},
})
await vi.waitFor(() => {
const renderedMessages = screen.container.querySelectorAll('.chat-message-item')
expect(renderedMessages.length).toBeGreaterThan(0)
expect(renderedMessages.length).toBeLessThan(messages.length)
})
const history = screen.container.querySelector<HTMLElement>('.chat-history-list')
expect(history).not.toBeNull()
if (!history)
throw new Error('Expected a desktop chat history viewport.')
expect(history.matches('[data-reka-scroll-area-viewport]')).toBe(true)
expect(screen.container.querySelectorAll('.chat-history-list')).toHaveLength(1)
await vi.waitFor(() => {
expect(screen.container.querySelector('.scrollable-area-scrollbar--vertical')).not.toBeNull()
expect(history.scrollHeight).toBeGreaterThan(history.clientHeight)
expect(screen.container.textContent).toContain('Desktop message 99')
})
history.scrollTop = 0
history.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => {
expect(screen.container.textContent).toContain('Desktop message 0')
})
})
// ROOT CAUSE:
//
// Virtua keeps its internal content root at least as tall as the viewport, but
@@ -112,6 +187,14 @@ describe('chat history', () => {
if (!history)
throw new Error('Expected a chat history viewport.')
expect(history.matches('[data-reka-scroll-area-viewport]')).toBe(false)
expect(screen.container.querySelectorAll('.chat-history-list')).toHaveLength(1)
await vi.waitFor(() => {
expect(history.scrollHeight).toBeGreaterThan(history.clientHeight)
})
expect(getComputedStyle(history).overflowY).toBe('auto')
expect(screen.container.querySelector('.scrollable-area-scrollbar--vertical')).toBeNull()
history.scrollTop = 0
history.dispatchEvent(new Event('scroll'))
@@ -9,6 +9,7 @@ import { computed, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import ChatAssistantItem from './assistant-item.vue'
import ChatHistoryScrollContainer from './chat-history-scroll-container.vue'
import ChatErrorItem from './error-item.vue'
import ChatHistoryMessageFrame from './history-message-frame.vue'
import ChatUserItem from './user-item.vue'
@@ -18,6 +19,10 @@ import { useChatHistoryTopFade } from '../composables/use-chat-history-top-fade'
import { useVirtualizerBottomAlignment, useVirtualizerScroll } from '../composables/use-virtualizer-scroll'
import { getChatHistoryItemKey } from '../utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<{
messages: ChatHistoryItem[]
streamingMessage?: StreamingAssistantMessage
@@ -44,7 +49,8 @@ const emit = defineEmits<{
/** Keeps about two mobile viewports ready so fast flicks do not expose an unmounted gap. */
const CHAT_HISTORY_OVERSCAN = 600
const chatHistoryRef = useTemplateRef<HTMLDivElement>('chatHistory')
const scrollContainerRef = useTemplateRef<InstanceType<typeof ChatHistoryScrollContainer>>('scroll-container')
const chatHistoryRef = computed<HTMLElement | null>(() => scrollContainerRef.value?.viewport ?? null)
const virtualizerRef = useTemplateRef<VirtualizerHandle>('virtualizer')
const { scrollToIndex } = useVirtualizerScroll(virtualizerRef)
@@ -134,20 +140,17 @@ function emitToolCallRerun(
</script>
<template>
<div
ref="chatHistory"
:class="[
'chat-history-list',
'relative h-full w-full overflow-y-auto rounded-xl',
'<sm:px-2 <sm:py-2',
variant === 'mobile' ? 'chat-history-list--mobile' : '',
]"
<ChatHistoryScrollContainer
ref="scroll-container"
v-bind="$attrs"
:variant="variant"
>
<Virtualizer
ref="virtualizer"
:data="renderMessages"
:buffer-size="CHAT_HISTORY_OVERSCAN"
:item-props="itemProps"
:scroll-ref="chatHistoryRef ?? undefined"
>
<template #default="{ item: message, index }">
<ChatHistoryMessageFrame
@@ -192,25 +195,5 @@ function emitToolCallRerun(
</ChatHistoryMessageFrame>
</template>
</Virtualizer>
</div>
</ChatHistoryScrollContainer>
</template>
<style scoped>
.chat-history-list--mobile :deep(.chat-message-item-container) {
--chat-top-fade-transparent-stop: -1px;
--chat-top-fade-opaque-stop: 0px;
-webkit-mask-image: linear-gradient(
to bottom,
transparent var(--chat-top-fade-transparent-stop),
black var(--chat-top-fade-opaque-stop)
);
mask-image: linear-gradient(
to bottom,
transparent var(--chat-top-fade-transparent-stop),
black var(--chat-top-fade-opaque-stop)
);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
}
</style>
@@ -0,0 +1,106 @@
import { ScrollableArea } from '@proj-airi/ui'
import { describe, expect, it } from 'vitest'
import { render } from 'vitest-browser-vue'
import { defineComponent, ref, shallowRef } from 'vue'
describe('shared scrollable area', () => {
it('exposes the Reka viewport and renders the requested scrollbar', async () => {
const area = shallowRef<InstanceType<typeof ScrollableArea>>()
const TestHost = defineComponent({
components: { ScrollableArea },
setup: () => ({ area }),
template: `
<ScrollableArea ref="area" type="always" style="height: 120px">
<div style="height: 240px">Scrollable content</div>
</ScrollableArea>
`,
})
const screen = await render(TestHost)
const viewport = screen.container.querySelector<HTMLElement>('[data-reka-scroll-area-viewport]')
expect(viewport).not.toBeNull()
expect(viewport?.clientHeight).toBe(120)
expect(screen.container.querySelector('[data-orientation="vertical"]')).not.toBeNull()
expect(area.value?.viewport).toBe(viewport)
})
it('renders both scrollbar orientations around the same viewport', async () => {
const TestHost = defineComponent({
components: { ScrollableArea },
template: `
<ScrollableArea orientation="both" type="always" style="height: 120px; width: 120px">
<div style="height: 240px; width: 240px">Two-axis content</div>
</ScrollableArea>
`,
})
const screen = await render(TestHost)
const viewport = screen.container.querySelector('[data-reka-scroll-area-viewport]')
const verticalTrack = screen.container.querySelector<HTMLElement>('[data-orientation="vertical"]')
const horizontalTrack = screen.container.querySelector<HTMLElement>('[data-orientation="horizontal"]')
const verticalThumb = verticalTrack?.firstElementChild as HTMLElement | null
const horizontalThumb = horizontalTrack?.firstElementChild as HTMLElement | null
expect(viewport).not.toBeNull()
expect(verticalThumb?.getBoundingClientRect().width).toBeGreaterThan(0)
expect(verticalThumb?.getBoundingClientRect().height).toBeGreaterThan(0)
expect(horizontalThumb?.getBoundingClientRect().width).toBeGreaterThan(0)
expect(horizontalThumb?.getBoundingClientRect().height).toBeGreaterThan(0)
expect(viewport?.textContent).toContain('Two-axis content')
})
// ROOT CAUSE:
// Reka UI 2.10.3 clears both enabled-axis flags when either scrollbar unmounts.
// Switching away from `both` therefore leaves the surviving axis disabled.
// We restore the requested flags after Reka finishes the scrollbar DOM update.
// Report: https://github.com/moeru-ai/airi/pull/2399#discussion_r3886316598
it('keeps the remaining axis scrollable when orientation changes', async () => {
const orientation = ref<'vertical' | 'horizontal' | 'both'>('both')
const TestHost = defineComponent({
components: { ScrollableArea },
setup: () => ({ orientation }),
template: `
<ScrollableArea :orientation="orientation" type="always" style="height: 120px; width: 120px">
<div style="height: 240px; width: 240px">Two-axis content</div>
</ScrollableArea>
`,
})
const screen = await render(TestHost)
const viewport = screen.container.querySelector<HTMLElement>('[data-reka-scroll-area-viewport]')
orientation.value = 'vertical'
await expect.poll(() => viewport?.style.overflowY).toBe('scroll')
expect(viewport?.style.overflowX).toBe('hidden')
expect(screen.container.querySelector('[data-orientation="vertical"]')).not.toBeNull()
expect(screen.container.querySelector('[data-orientation="horizontal"]')).toBeNull()
viewport!.scrollTop = 40
expect(viewport?.scrollTop).toBe(40)
orientation.value = 'horizontal'
await expect.poll(() => viewport?.style.overflowX).toBe('scroll')
expect(viewport?.style.overflowY).toBe('hidden')
expect(screen.container.querySelector('[data-orientation="vertical"]')).toBeNull()
expect(screen.container.querySelector('[data-orientation="horizontal"]')).not.toBeNull()
viewport!.scrollLeft = 40
expect(viewport?.scrollLeft).toBe(40)
})
it('keeps max-height panels scrollable without a fixed height', async () => {
const TestHost = defineComponent({
components: { ScrollableArea },
template: `
<ScrollableArea type="always" style="max-height: 120px">
<div style="height: 240px">Tall preview</div>
</ScrollableArea>
`,
})
const screen = await render(TestHost)
const viewport = screen.container.querySelector<HTMLElement>('[data-reka-scroll-area-viewport]')
expect(viewport?.clientHeight).toBe(120)
expect(viewport?.scrollHeight).toBe(240)
})
})
@@ -39,7 +39,10 @@ function sessionMeta(sessionId: string, updatedAt: number): ChatSessionMeta {
}
}
function createHarness() {
function createHarness(rows = [
{ meta: sessionMeta('session-one', 2), preview: 'First chat', isActive: true, updatedAtLabel: 'now' },
{ meta: sessionMeta('session-two', 1), preview: 'Second chat', isActive: false, updatedAtLabel: 'yesterday' },
]) {
return defineComponent({
name: 'SessionsDialogHarness',
components: { SessionsDialog },
@@ -52,10 +55,7 @@ function createHarness() {
created,
deleted,
selected,
rows: [
{ meta: sessionMeta('session-one', 2), preview: 'First chat', isActive: true, updatedAtLabel: 'now' },
{ meta: sessionMeta('session-two', 1), preview: 'Second chat', isActive: false, updatedAtLabel: 'yesterday' },
],
rows,
}
},
template: `
@@ -77,6 +77,30 @@ function createHarness() {
}
describe('sessions dialog actions', () => {
it('constrains long mobile session lists to a scrollable viewport', async () => {
const rows = Array.from({ length: 30 }, (_, index) => ({
meta: sessionMeta(`session-${index}`, 30 - index),
preview: `Chat ${index}`,
isActive: index === 0,
updatedAtLabel: 'now',
}))
await render(createHarness(rows), {
global: {
plugins: [createTestI18n()],
},
})
const viewport = document.querySelector<HTMLElement>('[data-reka-scroll-area-viewport]')
expect(viewport).not.toBeNull()
await expect.poll(() => viewport?.clientHeight ?? 0).toBeLessThan(viewport?.scrollHeight ?? 0)
await expect.poll(() => document.querySelector('.scrollable-area-scrollbar--vertical')).not.toBeNull()
viewport!.scrollTop = 120
expect(viewport?.scrollTop).toBe(120)
})
// https://github.com/moeru-ai/airi/issues/2085
it('keeps add, switch, and delete actions independent for Issue #2085', async () => {
// ROOT CAUSE:
@@ -91,6 +115,8 @@ describe('sessions dialog actions', () => {
},
})
expect(document.querySelector('[data-reka-scroll-area-viewport]')).not.toBeNull()
await screen.getByRole('button', { name: 'New chat' }).click()
await expect.element(screen.getByLabelText('created-session-count')).toHaveTextContent('1')
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { ChatSessionMeta } from '../../../../types/chat-session'
import { ScrollableArea } from '@proj-airi/ui'
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { useI18n } from 'vue-i18n'
@@ -75,7 +76,15 @@ const { t } = useI18n()
{{ t('stage.chat.sessions.new') }}
</button>
</div>
<div :class="['flex-1 overflow-y-auto px-2 pb-4']">
<ScrollableArea
:class="['min-h-0 flex-1']"
:style="{
maxHeight: isDesktop
? 'calc(80dvh - 4rem)'
: `calc(85dvh - ${mobilePaddingBottom} - 5rem)`,
}"
:viewport-class="['px-2 pb-4']"
>
<div v-if="rows.length === 0" :class="['p-6 text-center text-sm text-neutral-500 dark:text-neutral-400']">
{{ t('stage.chat.sessions.empty') }}
</div>
@@ -124,7 +133,7 @@ const { t } = useI18n()
<div class="i-solar:trash-bin-trash-bold-duotone h-4 w-4" />
</button>
</div>
</div>
</ScrollableArea>
</div>
</DialogContent>
</DialogPortal>
@@ -1,10 +1,10 @@
import type { Ref, ShallowRef } from 'vue'
import type { Ref } from 'vue'
import { useEventListener } from '@vueuse/core'
import { computed, watch } from 'vue'
interface ChatHistoryScrollOptions<TMessage> {
container: Readonly<ShallowRef<HTMLElement | null>>
container: Readonly<Ref<HTMLElement | null>>
messages: Readonly<Ref<TMessage[]>>
getKey: (message: TMessage, index: number) => string | number
scrollToIndex: (index: number, align: 'start' | 'end') => void
@@ -10,7 +10,7 @@ interface VirtualScrollRequest {
}
interface VirtualizerBottomAlignmentOptions {
container: Readonly<ShallowRef<HTMLElement | null>>
container: Readonly<Ref<HTMLElement | null>>
itemCount: Readonly<Ref<number>>
virtualizer: Readonly<ShallowRef<VirtualizerHandle | null>>
}
@@ -32,7 +32,7 @@ onMounted(() => screenSafeArea.update())
<DialogRoot v-if="isDesktop" :open="showDialog" @update:open="value => showDialog = value">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-9999 bg-black/50 backdrop-blur-sm data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn" />
<DialogContent class="fixed left-1/2 top-1/2 z-9999 max-h-full max-w-2xl w-[92dvw] flex flex-col overflow-hidden rounded-2xl bg-white p-6 shadow-xl outline-none backdrop-blur-md scrollbar-none -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:bg-neutral-900">
<DialogContent class="fixed left-1/2 top-1/2 z-9999 h-[min(100dvh,48rem)] max-h-full max-w-2xl w-[92dvw] flex flex-col overflow-hidden rounded-2xl bg-white p-6 shadow-xl outline-none backdrop-blur-md scrollbar-none -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:bg-neutral-900">
<VisuallyHidden>
<DialogTitle>Onboarding</DialogTitle>
</VisuallyHidden>
@@ -3,7 +3,7 @@ import type { ProviderMetadata } from '../../../../libs/providers/metadata'
import type { OnboardingStepNextHandler, OnboardingStepPrevHandler } from './types'
import { errorMessageFrom } from '@moeru/std'
import { Button, Callout, FieldCheckbox, FieldInput } from '@proj-airi/ui'
import { Button, Callout, FieldCheckbox, FieldInput, ScrollableArea } from '@proj-airi/ui'
import { computedAsync } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -230,97 +230,99 @@ initializeForm()
</h2>
<div h-5 w-5 />
</div>
<div v-if="props.selectedProvider" flex-1 overflow-y-auto space-y-4>
<Callout :label="t('settings.dialogs.onboarding.credentialsSafeLabel')" theme="violet">
<div>
<ScrollableArea v-if="props.selectedProvider" :class="['min-h-0 flex-1']">
<div :class="['space-y-4']">
<Callout :label="t('settings.dialogs.onboarding.credentialsSafeLabel')" theme="violet">
<div>
{{ t('settings.dialogs.onboarding.credentialsSafeLocal') }}
</div>
<div>
<i18n-t keypath="settings.dialogs.onboarding.credentialsSafeOpenSource" tag="span">
<template #github>
<span inline-flex translate-y-1 items-center gap-1>
<span i-simple-icons:github inline-block /><a decoration-underline decoration-dashed href="https://github.com/moeru-ai/airi" target="_blank" rel="noopener noreferrer">GitHub</a>
</span>
</template>
</i18n-t>
<div>
{{ t('settings.dialogs.onboarding.credentialsSafeLocal') }}
</div>
<div>
<i18n-t keypath="settings.dialogs.onboarding.credentialsSafeOpenSource" tag="span">
<template #github>
<span inline-flex translate-y-1 items-center gap-1>
<span i-simple-icons:github inline-block /><a decoration-underline decoration-dashed href="https://github.com/moeru-ai/airi" target="_blank" rel="noopener noreferrer">GitHub</a>
</span>
</template>
</i18n-t>
</div>
</div>
</Callout>
<div class="space-y-4">
<!-- Custom onboarding fields (provider-specific, e.g. Amazon Bedrock SigV4) -->
<template v-if="hasOnboardingFields">
<FieldInput
v-for="field in props.selectedProvider.onboardingFields"
:key="field.key"
v-model="customFieldValues[field.key]"
:type="field.type"
:label="field.label"
:description="field.description"
:placeholder="field.placeholder || ''"
:required="field.required"
/>
</template>
<!-- Standard fields for other providers -->
<template v-else>
<!-- API Key Input -->
<div v-if="needsApiKey">
<FieldInput
v-model="apiKey"
:placeholder="getApiKeyPlaceholder(props.selectedProvider.id)"
type="password"
label="API Key"
description="Enter your API key for the selected provider."
required
/>
</div>
<!-- Base URL Input -->
<div v-if="needsBaseUrl">
<FieldInput
v-model="baseUrl"
:placeholder="getBaseUrlPlaceholder(props.selectedProvider.id)"
type="text"
label="Base URL"
description="Enter the base URL for the provider's API."
/>
</div>
<!-- Account ID for Cloudflare -->
<div v-if="props.selectedProvider.id === 'cloudflare-workers-ai'">
<ProviderAccountIdInput v-model="accountId" />
</div>
</template>
</div>
</Callout>
<div class="space-y-4">
<!-- Custom onboarding fields (provider-specific, e.g. Amazon Bedrock SigV4) -->
<template v-if="hasOnboardingFields">
<FieldInput
v-for="field in props.selectedProvider.onboardingFields"
:key="field.key"
v-model="customFieldValues[field.key]"
:type="field.type"
:label="field.label"
:description="field.description"
:placeholder="field.placeholder || ''"
:required="field.required"
/>
</template>
<!-- Standard fields for other providers -->
<template v-else>
<!-- API Key Input -->
<div v-if="needsApiKey">
<FieldInput
v-model="apiKey"
:placeholder="getApiKeyPlaceholder(props.selectedProvider.id)"
type="password"
label="API Key"
description="Enter your API key for the selected provider."
required
/>
</div>
<!-- Chat Ping Check Option -->
<FieldCheckbox
v-if="showChatCheckOption"
v-model="enableChatCheck"
:label="t('settings.dialogs.onboarding.enableChatCheck')"
placement="left"
/>
<!-- Base URL Input -->
<div v-if="needsBaseUrl">
<FieldInput
v-model="baseUrl"
:placeholder="getBaseUrlPlaceholder(props.selectedProvider.id)"
type="text"
label="Base URL"
description="Enter the base URL for the provider's API."
/>
</div>
<!-- Account ID for Cloudflare -->
<div v-if="props.selectedProvider.id === 'cloudflare-workers-ai'">
<ProviderAccountIdInput v-model="accountId" />
</div>
</template>
<!-- Validation Status -->
<Alert v-if="validation === 'failed'" type="error">
<template #title>
<div class="w-full flex items-center justify-between">
<span>{{ t('settings.dialogs.onboarding.validationFailed') }}</span>
<button
type="button"
class="ml-2 rounded bg-red-100 px-2 py-0.5 text-xs text-red-600 font-medium transition-colors dark:bg-red-800/30 hover:bg-red-200 dark:text-red-300 dark:hover:bg-red-700/40"
@click="handleContinueAnyway"
>
{{ t('settings.pages.providers.common.continueAnyway') }}
</button>
</div>
</template>
<template v-if="validationError" #content>
<pre class="whitespace-pre-wrap break-all">{{ String(validationError) }}</pre>
</template>
</Alert>
</div>
<!-- Chat Ping Check Option -->
<FieldCheckbox
v-if="showChatCheckOption"
v-model="enableChatCheck"
:label="t('settings.dialogs.onboarding.enableChatCheck')"
placement="left"
/>
<!-- Validation Status -->
<Alert v-if="validation === 'failed'" type="error">
<template #title>
<div class="w-full flex items-center justify-between">
<span>{{ t('settings.dialogs.onboarding.validationFailed') }}</span>
<button
type="button"
class="ml-2 rounded bg-red-100 px-2 py-0.5 text-xs text-red-600 font-medium transition-colors dark:bg-red-800/30 hover:bg-red-200 dark:text-red-300 dark:hover:bg-red-700/40"
@click="handleContinueAnyway"
>
{{ t('settings.pages.providers.common.continueAnyway') }}
</button>
</div>
</template>
<template v-if="validationError" #content>
<pre class="whitespace-pre-wrap break-all">{{ String(validationError) }}</pre>
</template>
</Alert>
</div>
</ScrollableArea>
<!-- Action Buttons -->
<Button
@@ -2,7 +2,7 @@
import type { ProviderMetadata } from '../../../../libs/providers/metadata'
import type { OnboardingStepNextHandler, OnboardingStepPrevHandler } from './types'
import { Button } from '@proj-airi/ui'
import { Button, ScrollableArea } from '@proj-airi/ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -40,7 +40,7 @@ const selectedProviderIdModel = computed({
</h2>
<div class="h-5 w-5" />
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
<ScrollableArea :class="['min-h-0 flex-1']">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<RadioCardDetail
v-for="provider in props.popularProviders"
@@ -54,7 +54,7 @@ const selectedProviderIdModel = computed({
@click="props.onSelectProvider(provider)"
/>
</div>
</div>
</ScrollableArea>
<Button
class="flex-shrink-0"
:label="t('settings.dialogs.onboarding.next')"
@@ -8,7 +8,7 @@ import type {
import type { DisplayModel } from '../../../../stores/display-models'
import type { ModelSettingsRuntimeSnapshot } from './runtime'
import { Button, Callout } from '@proj-airi/ui'
import { Button, Callout, ScrollableArea } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -67,85 +67,86 @@ async function handleModelPick(selectedModel: DisplayModel | undefined) {
</script>
<template>
<div
<ScrollableArea
:class="[
'flex flex-col gap-2',
'z-10 overflow-y-scroll p-2',
'z-10',
settingsClass,
]"
>
<Callout :label="t('settings.model-select.panel-callout.support-status-header')">
<i18n-t keypath="settings.model-select.panel-callout.support-status" tag="p">
<template #select-button>
<strong>{{ t('settings.model-select.select-model.button') }}</strong>
</template>
<template #zip>
<code>.zip</code>
</template>
<template #vrm>
<code>.vrm</code>
</template>
<template #mmd>
<code>.pmx</code>/<code>.pmd</code>
</template>
</i18n-t>
<p>
{{ t('settings.model-select.panel-callout.model-type-example') }}
</p>
<p v-if="effectiveRenderer === 'tachie'">
{{ t('settings.tachie.archive-description') }}
</p>
</Callout>
<div :class="['flex flex-wrap items-center gap-2']">
<ModelSelectorDialog v-model:show="modelSelectorOpen" :selected-model="stageModelSelectedDisplayModel" @pick="handleModelPick">
<Button>
{{ t('settings.model-select.select-model.button') }}
</Button>
</ModelSelectorDialog>
<slot name="actions" />
<div :class="['flex flex-col gap-2 p-2']">
<Callout :label="t('settings.model-select.panel-callout.support-status-header')">
<i18n-t keypath="settings.model-select.panel-callout.support-status" tag="p">
<template #select-button>
<strong>{{ t('settings.model-select.select-model.button') }}</strong>
</template>
<template #zip>
<code>.zip</code>
</template>
<template #vrm>
<code>.vrm</code>
</template>
<template #mmd>
<code>.pmx</code>/<code>.pmd</code>
</template>
</i18n-t>
<p>
{{ t('settings.model-select.panel-callout.model-type-example') }}
</p>
<p v-if="effectiveRenderer === 'tachie'">
{{ t('settings.tachie.archive-description') }}
</p>
</Callout>
<div :class="['flex flex-wrap items-center gap-2']">
<ModelSelectorDialog v-model:show="modelSelectorOpen" :selected-model="stageModelSelectedDisplayModel" @pick="handleModelPick">
<Button>
{{ t('settings.model-select.select-model.button') }}
</Button>
</ModelSelectorDialog>
<slot name="actions" />
</div>
<Live2D
v-if="effectiveRenderer === 'live2d'"
:allow-extract-colors="allowExtractColors"
:palette="palette"
:runtime-snapshot="runtimeSnapshot"
@extract-colors-from-model="emit('extractColorsFromModel')"
/>
<VRM
v-if="effectiveRenderer === 'vrm'"
:allow-extract-colors="allowExtractColors"
:palette="palette"
:runtime-snapshot="runtimeSnapshot"
@extract-colors-from-model="emit('extractColorsFromModel')"
/>
<Spine
v-if="effectiveRenderer === 'spine'"
:allow-extract-colors="allowExtractColors"
:palette="palette"
:runtime-snapshot="runtimeSnapshot"
@extract-colors-from-model="$emit('extractColorsFromModel')"
/>
<MMD
v-if="effectiveRenderer === 'mmd'"
:allow-extract-colors="allowExtractColors"
:palette="palette"
:runtime-snapshot="runtimeSnapshot"
@extract-colors-from-model="emit('extractColorsFromModel')"
/>
<Tachie
v-if="effectiveRenderer === 'tachie'"
:allow-extract-colors="allowExtractColors"
:palette="palette"
:runtime-snapshot="runtimeSnapshot"
@extract-colors-from-model="emit('extractColorsFromModel')"
/>
<Godot
v-if="effectiveRenderer === 'godot'"
:runtime-snapshot="runtimeSnapshot"
:view-snapshot="godotViewSnapshot"
:view-error="godotViewError"
:view-controls-locked="godotViewControlsLocked"
@patch-view-state="emit('patchGodotViewState', $event)"
/>
</div>
<Live2D
v-if="effectiveRenderer === 'live2d'"
:allow-extract-colors="allowExtractColors"
:palette="palette"
:runtime-snapshot="runtimeSnapshot"
@extract-colors-from-model="emit('extractColorsFromModel')"
/>
<VRM
v-if="effectiveRenderer === 'vrm'"
:allow-extract-colors="allowExtractColors"
:palette="palette"
:runtime-snapshot="runtimeSnapshot"
@extract-colors-from-model="emit('extractColorsFromModel')"
/>
<Spine
v-if="effectiveRenderer === 'spine'"
:allow-extract-colors="allowExtractColors"
:palette="palette"
:runtime-snapshot="runtimeSnapshot"
@extract-colors-from-model="$emit('extractColorsFromModel')"
/>
<MMD
v-if="effectiveRenderer === 'mmd'"
:allow-extract-colors="allowExtractColors"
:palette="palette"
:runtime-snapshot="runtimeSnapshot"
@extract-colors-from-model="emit('extractColorsFromModel')"
/>
<Tachie
v-if="effectiveRenderer === 'tachie'"
:allow-extract-colors="allowExtractColors"
:palette="palette"
:runtime-snapshot="runtimeSnapshot"
@extract-colors-from-model="emit('extractColorsFromModel')"
/>
<Godot
v-if="effectiveRenderer === 'godot'"
:runtime-snapshot="runtimeSnapshot"
:view-snapshot="godotViewSnapshot"
:view-error="godotViewError"
:view-controls-locked="godotViewControlsLocked"
@patch-view-state="emit('patchGodotViewState', $event)"
/>
</div>
</ScrollableArea>
</template>
@@ -1,4 +1,5 @@
export { default as Collapsible } from './collapsible.vue'
export { default as Screen } from './screen.vue'
export { default as ScrollableArea } from './scrollable-area.vue'
export { default as Skeleton } from './skeleton.vue'
export { default as Truncatable } from './truncatable.vue'
@@ -0,0 +1,147 @@
<script setup lang="ts">
import type { ScrollAreaRootProps } from 'reka-ui'
import type { PropType } from 'vue'
import { injectScrollAreaRootContext, ScrollAreaCorner, ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
import { computed, defineComponent, nextTick, useTemplateRef, watch } from 'vue'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<ScrollableAreaProps>(), {
contentAsChild: false,
orientation: 'vertical',
type: 'auto',
viewportClass: undefined,
})
type ScrollableAreaOrientation = 'vertical' | 'horizontal' | 'both'
// NOTICE:
// Reka UI 2.10.3 clears both axis flags when either scrollbar unmounts.
// Restore the requested flags after the scrollbar DOM update.
// Source: https://github.com/moeru-ai/airi/pull/2399#discussion_r3886316598
// Remove this controller when Reka only clears the axis owned by its scrollbar.
const ScrollAreaAxisController = defineComponent({
props: {
orientation: {
type: String as PropType<ScrollableAreaOrientation>,
required: true,
},
},
setup(controllerProps) {
const rootContext = injectScrollAreaRootContext()
watch(
() => controllerProps.orientation,
async (orientation) => {
await nextTick()
rootContext.onScrollbarXEnabledChange(orientation === 'horizontal' || orientation === 'both')
rootContext.onScrollbarYEnabledChange(orientation === 'vertical' || orientation === 'both')
},
{ flush: 'post', immediate: true },
)
return () => null
},
})
interface ScrollableAreaProps {
contentAsChild?: boolean
orientation?: ScrollableAreaOrientation
type?: ScrollAreaRootProps['type']
viewportClass?: string | string[]
}
const rootRef = useTemplateRef<{ viewport: HTMLElement | undefined }>('root')
const viewport = computed(() => rootRef.value?.viewport)
defineExpose({
viewport,
})
</script>
<template>
<ScrollAreaRoot
ref="root"
v-bind="$attrs"
:type="props.type"
:class="[
'relative min-h-0 min-w-0 overflow-hidden',
]"
>
<ScrollAreaAxisController :orientation="props.orientation" />
<ScrollAreaViewport
:as-child="props.contentAsChild"
:style="{
height: '100%',
maxHeight: 'inherit',
maxWidth: 'inherit',
width: '100%',
}"
:class="[
'h-full w-full rounded-[inherit]',
props.viewportClass,
]"
>
<slot />
</ScrollAreaViewport>
<ScrollAreaScrollbar
v-if="props.orientation === 'vertical' || props.orientation === 'both'"
orientation="vertical"
:style="{
display: 'flex',
width: '0.625rem',
}"
:class="[
'scrollable-area-scrollbar scrollable-area-scrollbar--vertical',
'z-10 touch-none select-none p-0.5',
'transition-colors duration-150',
]"
>
<ScrollAreaThumb
:style="{
width: '100%',
}"
:class="[
'scrollable-area-thumb--vertical',
'relative rounded-full',
'bg-neutral-400/55 hover:bg-neutral-500/70',
'dark:bg-neutral-600/65 dark:hover:bg-neutral-500/80',
]"
/>
</ScrollAreaScrollbar>
<ScrollAreaScrollbar
v-if="props.orientation === 'horizontal' || props.orientation === 'both'"
orientation="horizontal"
:style="{
display: 'flex',
flexDirection: 'column',
height: '0.625rem',
}"
:class="[
'scrollable-area-scrollbar scrollable-area-scrollbar--horizontal',
'z-10 touch-none select-none p-0.5',
'transition-colors duration-150',
]"
>
<ScrollAreaThumb
:style="{
height: '100%',
}"
:class="[
'scrollable-area-thumb--horizontal',
'relative rounded-full',
'bg-neutral-400/55 hover:bg-neutral-500/70',
'dark:bg-neutral-600/65 dark:hover:bg-neutral-500/80',
]"
/>
</ScrollAreaScrollbar>
<ScrollAreaCorner v-if="props.orientation === 'both'" />
</ScrollAreaRoot>
</template>
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { errorCauseFrom, errorMessageFrom, errorNameFrom, errorStackFrom } from '@moeru/std'
import { ScrollAreaCorner, ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
import { computed, shallowRef } from 'vue'
import ScrollableArea from '../layouts/scrollable-area.vue'
import IconButton from './icon-button.vue'
type HeightPreset = 'sm' | 'md' | 'lg' | 'xl' | 'auto'
@@ -178,32 +178,26 @@ async function copyContent() {
/>
</div>
<ScrollAreaRoot
type="auto"
<ScrollableArea
:class="[
'relative w-full overflow-hidden rounded-xl',
...heightPresetClasses[heightPreset],
]"
:viewport-class="['h-full w-full']"
>
<ScrollAreaViewport :class="['h-full w-full']">
<div :class="['flex flex-col gap-2 p-3 text-xs']">
<div v-if="resolvedErrorName || resolvedMessage" :class="['font-mono text-red-700 leading-relaxed dark:text-red-300']">
{{ resolvedErrorName || 'Error' }}
<span v-if="resolvedMessage">
: {{ resolvedMessage }}
</span>
</div>
<pre v-if="resolvedStack" :class="['whitespace-pre-wrap break-words text-neutral-700 leading-relaxed dark:text-neutral-200']"> {{ resolvedStack }}</pre>
<pre v-if="resolvedCause" :class="['whitespace-pre-wrap break-words text-neutral-700 leading-relaxed dark:text-neutral-200']">{{ `Cause:\n${resolvedCause}` }}</pre>
<div v-if="!panelContent" :class="['text-neutral-600 dark:text-neutral-300']">
No error details available.
</div>
<div :class="['flex flex-col gap-2 p-3 text-xs']">
<div v-if="resolvedErrorName || resolvedMessage" :class="['font-mono text-red-700 leading-relaxed dark:text-red-300']">
{{ resolvedErrorName || 'Error' }}
<span v-if="resolvedMessage">
: {{ resolvedMessage }}
</span>
</div>
</ScrollAreaViewport>
<ScrollAreaScrollbar orientation="vertical" :class="['w-2 p-0.5']">
<ScrollAreaThumb :class="['rounded-full bg-neutral-300/80 dark:bg-neutral-700/80']" />
</ScrollAreaScrollbar>
<ScrollAreaCorner />
</ScrollAreaRoot>
<pre v-if="resolvedStack" :class="['whitespace-pre-wrap break-words text-neutral-700 leading-relaxed dark:text-neutral-200']"> {{ resolvedStack }}</pre>
<pre v-if="resolvedCause" :class="['whitespace-pre-wrap break-words text-neutral-700 leading-relaxed dark:text-neutral-200']">{{ `Cause:\n${resolvedCause}` }}</pre>
<div v-if="!panelContent" :class="['text-neutral-600 dark:text-neutral-300']">
No error details available.
</div>
</div>
</ScrollableArea>
</div>
</template>