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
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Button } from '@proj-airi/ui'
import { Button, ScrollableArea } from '@proj-airi/ui'
import { useI18n } from 'vue-i18n'
import PermissionsPanel from '../permissions/permissions-panel.vue'
@@ -25,17 +25,19 @@ const { t } = useI18n()
<div h-5 w-5 />
</div>
<div flex-1 overflow-y-auto space-y-4>
<p class="text-sm text-neutral-600 md:text-base dark:text-neutral-300">
{{ t('settings.dialogs.onboarding.permissions.description') }}
</p>
<ScrollableArea :class="['min-h-0 flex-1']">
<div :class="['space-y-4']">
<p class="text-sm text-neutral-600 md:text-base dark:text-neutral-300">
{{ t('settings.dialogs.onboarding.permissions.description') }}
</p>
<PermissionsPanel />
<PermissionsPanel />
<p :class="['text-xs', 'text-neutral-500 dark:text-neutral-400']">
{{ t('settings.dialogs.onboarding.permissions.optionalHint') }}
</p>
</div>
<p :class="['text-xs', 'text-neutral-500 dark:text-neutral-400']">
{{ t('settings.dialogs.onboarding.permissions.optionalHint') }}
</p>
</div>
</ScrollableArea>
<Button
:label="t('settings.dialogs.onboarding.next')"
@@ -19,6 +19,9 @@ import { createMemoryHistory, createRouter } from 'vue-router'
import InteractiveArea from './InteractiveArea.vue'
import '@unocss/reset/tailwind.css'
import 'virtual:uno.css'
function createTestI18n() {
return createI18n({
legacy: false,
@@ -80,7 +83,94 @@ async function submitDraft(screen: Awaited<ReturnType<typeof renderArea>>['scree
return input
}
async function attachImages(screen: Awaited<ReturnType<typeof renderArea>>['screen'], count: number) {
const input = screen.container.querySelector<HTMLInputElement>('input[type="file"]')
if (!input)
throw new Error('Expected the chat image input.')
const transfer = new DataTransfer()
for (let index = 0; index < count; index++) {
transfer.items.add(new File([`image-${index}`], `image-${index}.png`, { type: 'image/png' }))
}
input.files = transfer.files
input.dispatchEvent(new Event('change', { bubbles: true }))
await vi.waitFor(() => {
expect(screen.container.querySelectorAll('img[src^="blob:"]')).toHaveLength(count)
})
}
describe('interactive area synchronized state', () => {
// https://github.com/moeru-ai/airi/pull/2399
it('keeps the input visible when a short window contains many attachments', async () => {
// ROOT CAUSE:
//
// The composer used its full intrinsic height in the fixed chat grid.
// Multiple attachment rows could exceed the window height, which moved
// the input below the clipped grid boundary.
const { screen } = await renderArea()
const layout = screen.getByTestId('chat-viewport-layout').element() as HTMLElement
layout.style.height = '240px'
layout.style.width = '320px'
await attachImages(screen, 12)
const input = screen.getByRole('textbox').element() as HTMLTextAreaElement
const layoutRect = layout.getBoundingClientRect()
const inputRect = input.getBoundingClientRect()
expect(inputRect.top).toBeGreaterThanOrEqual(layoutRect.top)
expect(inputRect.bottom).toBeLessThanOrEqual(layoutRect.bottom)
})
// https://github.com/moeru-ai/airi/pull/2399
it('connects the production history viewport to the fixed composer', async () => {
// ROOT CAUSE:
//
// Isolated layout tests used hand-built history and scrollbar elements.
// Those tests could pass after the production history stopped using the
// Reka viewport or moved the composer into the scroll owner.
const { chatSession, screen } = await renderArea()
const layout = screen.getByTestId('chat-viewport-layout').element() as HTMLElement
layout.style.height = '320px'
layout.style.width = '320px'
chatSession.$patch((state) => {
state.sessionMessages['session-b'] = Array.from({ length: 100 }, (_, index) => ({
id: `message-${index}`,
role: 'user',
content: `Message ${index}`,
createdAt: index,
}))
})
const viewport = screen.container.querySelector<HTMLElement>('.chat-history-list')
const composer = screen.getByTestId('chat-composer-layer').element() as HTMLElement
const input = screen.getByRole('textbox').element() as HTMLTextAreaElement
expect(viewport).not.toBeNull()
if (!viewport)
throw new Error('Expected the production chat history viewport.')
await vi.waitFor(() => {
expect(viewport.matches('[data-reka-scroll-area-viewport]')).toBe(true)
expect(viewport.scrollHeight).toBeGreaterThan(viewport.clientHeight)
})
expect(composer.contains(input)).toBe(true)
const composerTop = composer.getBoundingClientRect().top
viewport.scrollTop = 120
viewport.dispatchEvent(new Event('scroll'))
expect(composer.getBoundingClientRect().top).toBe(composerTop)
const scrollOwners = [...layout.querySelectorAll<HTMLElement>('*')]
.filter((element) => {
return ['auto', 'scroll'].includes(getComputedStyle(element).overflowY)
&& element.scrollHeight > element.clientHeight
})
expect(scrollOwners).toEqual([viewport])
})
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743121861
it('renders the active synchronized stream through the real chat history for Issue #2085', async () => {
// ROOT CAUSE:
@@ -21,6 +21,7 @@ import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import JournalToolCallBlock from './chat-tool-renderers/journal-tool-call-block.vue'
import ChatViewportLayout from './chat-viewport-layout.vue'
import { useHearingInputChannel } from '../composables/use-hearing-input-channel'
import { artistryToolReferences, widgetToolReferences } from '../stores/tools'
@@ -266,8 +267,8 @@ async function handleCleanupMessages() {
</script>
<template>
<div h-full w-full flex="~ col gap-1">
<div w-full flex-1 overflow-hidden>
<ChatViewportLayout>
<template #history>
<ChatHistory
:messages="historyMessages"
:assistant-label="assistantLabel"
@@ -278,192 +279,207 @@ async function handleCleanupMessages() {
@retry-message="handleRetryMessage($event.index)"
@tool-call-rerun="handleToolCallRerun"
/>
</div>
</template>
<!-- Journal Preview Chips -->
<div v-if="latestImageEntries.length > 0" class="flex gap-2 overflow-x-auto px-2 py-1 scrollbar-none">
<template #composer>
<div
v-for="entry in latestImageEntries"
:key="entry.id"
:class="[
'group relative h-14 w-14 shrink-0 cursor-pointer of-hidden rounded-lg',
'border border-primary-200/30 transition-all hover:border-primary-500',
'dark:border-primary-800/30 dark:hover:border-primary-400',
'min-h-0 max-h-full flex flex-col gap-1 overflow-hidden',
]"
@click="openImagePreview(entry)"
>
<img :src="entry.url || ''" class="h-full w-full object-cover">
<div :class="['absolute inset-0 flex items-end p-1', 'bg-gradient-to-t from-black/60 to-transparent']">
<span class="truncate text-[8px] text-white font-medium">{{ entry.title }}</span>
</div>
<div
data-testid="chat-composer-previews"
:class="[
'min-h-0 overflow-y-auto scrollbar-none',
]"
>
<!-- Journal Preview Chips -->
<div v-if="latestImageEntries.length > 0" class="flex gap-2 overflow-x-auto px-2 py-1 scrollbar-none">
<div
v-for="entry in latestImageEntries"
:key="entry.id"
:class="[
'group relative h-14 w-14 shrink-0 cursor-pointer of-hidden rounded-lg',
'border border-primary-200/30 transition-all hover:border-primary-500',
'dark:border-primary-800/30 dark:hover:border-primary-400',
]"
@click="openImagePreview(entry)"
>
<img :src="entry.url || ''" class="h-full w-full object-cover">
<div :class="['absolute inset-0 flex items-end p-1', 'bg-gradient-to-t from-black/60 to-transparent']">
<span class="truncate text-[8px] text-white font-medium">{{ entry.title }}</span>
</div>
<!-- Save Button (Top Right, Hover Only) -->
<button
:class="[
'absolute right-1 top-1 z-10 p-1 rounded-md bg-black/40 text-white backdrop-blur-sm',
'opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/60',
]"
title="Save to computer"
@click.stop="journalPreviewStore.downloadImage(entry.url || '', entry.title)"
>
<div class="i-solar:download-minimalistic-bold-duotone text-[10px]" />
</button>
</div>
</div>
<div
v-if="attachments.length > 0"
:class="[
'flex flex-wrap gap-2 border-t border-primary-100 p-2',
]"
>
<div v-for="(attachment, index) in attachments" :key="index" class="relative">
<img :src="attachment.url" :class="['h-20 w-20 rounded-md object-cover']">
<button
:class="[
'absolute right-1 top-1 h-5 w-5 flex items-center justify-center rounded-full',
'bg-red-500 text-xs text-white',
]"
@click="removeAttachment(index)"
>
&times;
</button>
</div>
</div>
<div :class="['flex items-center justify-end gap-2 py-1']">
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<button
<!-- Save Button (Top Right, Hover Only) -->
<button
:class="[
'absolute right-1 top-1 z-10 p-1 rounded-md bg-black/40 text-white backdrop-blur-sm',
'opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/60',
]"
title="Save to computer"
@click.stop="journalPreviewStore.downloadImage(entry.url || '', entry.title)"
>
<div class="i-solar:download-minimalistic-bold-duotone text-[10px]" />
</button>
</div>
</div>
<div
v-if="attachments.length > 0"
:class="[
'max-h-[10lh] min-h-[1lh] flex items-center justify-center rounded-md p-2 outline-none',
'transition-colors transition-transform active:scale-95',
'flex flex-nowrap gap-2 overflow-x-auto border-t border-primary-100 p-2 scrollbar-none',
]"
>
<div v-for="(attachment, index) in attachments" :key="index" class="relative shrink-0">
<img :src="attachment.url" :class="['h-20 w-20 rounded-md object-cover']">
<button
:class="[
'absolute right-1 top-1 h-5 w-5 flex items-center justify-center rounded-full',
'bg-red-500 text-xs text-white',
]"
@click="removeAttachment(index)"
>
&times;
</button>
</div>
</div>
</div>
<div :class="['flex shrink-0 items-center justify-end gap-2 py-1']">
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<button
:class="[
'max-h-[10lh] min-h-[1lh] flex items-center justify-center rounded-md p-2 outline-none',
'transition-colors transition-transform active:scale-95',
]"
bg="neutral-100 dark:neutral-800"
text="lg neutral-500 dark:neutral-400"
:title="t('stage.send-mode.title')"
>
<div class="i-solar:keyboard-bold-duotone" />
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
align="end"
side="top"
:side-offset="8"
:class="[
'z-50 min-w-[180px] rounded-xl p-1 shadow',
'bg-white dark:bg-neutral-800',
'flex flex-col gap-1',
'data-[side=top]:animate-slideDownAndFade',
'data-[side=left]:animate-none',
'data-[side=bottom]:animate-none',
'data-[side=right]:animate-none',
]"
>
<DropdownMenuItem
v-for="mode in SEND_MODES"
:key="mode"
:class="[
'w-full flex cursor-pointer items-center rounded-md px-3 py-2 text-left text-xs outline-none transition-colors',
'hover:bg-primary-50 dark:hover:bg-primary-900/20',
sendMode === mode ? 'bg-primary-50 text-primary-600 font-semibold dark:bg-primary-900/20 dark:text-primary-300' : 'text-neutral-500',
]"
@select="sendMode = mode"
>
<div class="mr-2 h-4 w-4 flex shrink-0 items-center justify-center">
<div v-if="sendMode === mode" class="i-ph:check-bold text-base" />
</div>
<span>{{ sendModeLabels[mode] }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
<button
v-if="showStopSpeakingButton"
data-testid="stop-speaking-button"
:class="[
'max-h-[10lh] min-h-[1lh]',
]"
bg="neutral-100 dark:neutral-800"
text="lg neutral-500 dark:neutral-400"
:title="t('stage.send-mode.title')"
hover:text="primary-500 dark:primary-400"
flex items-center justify-center rounded-md p-2 outline-none
transition-colors transition-transform active:scale-95
title="Stop speaking"
aria-label="Stop speaking"
@click="stopSpeakingFromChat"
>
<div class="i-solar:keyboard-bold-duotone" />
<div class="i-solar:stop-circle-bold-duotone" />
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
align="end"
side="top"
:side-offset="8"
<button
:class="[
'z-50 min-w-[180px] rounded-xl p-1 shadow',
'bg-white dark:bg-neutral-800',
'flex flex-col gap-1',
'data-[side=top]:animate-slideDownAndFade',
'data-[side=left]:animate-none',
'data-[side=bottom]:animate-none',
'data-[side=right]:animate-none',
'max-h-[10lh] min-h-[1lh]',
]"
bg="neutral-100 dark:neutral-800"
text="lg neutral-500 dark:neutral-400"
hover:text="red-500 dark:red-400"
flex items-center justify-center rounded-md p-2 outline-none
transition-colors transition-transform active:scale-95
@click="handleCleanupMessages"
>
<DropdownMenuItem
v-for="mode in SEND_MODES"
:key="mode"
:class="[
'w-full flex cursor-pointer items-center rounded-md px-3 py-2 text-left text-xs outline-none transition-colors',
'hover:bg-primary-50 dark:hover:bg-primary-900/20',
sendMode === mode ? 'bg-primary-50 text-primary-600 font-semibold dark:bg-primary-900/20 dark:text-primary-300' : 'text-neutral-500',
]"
@select="sendMode = mode"
>
<div class="mr-2 h-4 w-4 flex shrink-0 items-center justify-center">
<div v-if="sendMode === mode" class="i-ph:check-bold text-base" />
</div>
<span>{{ sendModeLabels[mode] }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
<div class="i-solar:trash-bin-2-bold-duotone" />
</button>
<button
v-if="showStopSpeakingButton"
data-testid="stop-speaking-button"
:class="[
'max-h-[10lh] min-h-[1lh]',
]"
bg="neutral-100 dark:neutral-800"
text="lg neutral-500 dark:neutral-400"
hover:text="primary-500 dark:primary-400"
flex items-center justify-center rounded-md p-2 outline-none
transition-colors transition-transform active:scale-95
title="Stop speaking"
aria-label="Stop speaking"
@click="stopSpeakingFromChat"
>
<div class="i-solar:stop-circle-bold-duotone" />
</button>
<!-- Image Journal Deep Link -->
<button
class="max-h-[10lh] min-h-[1lh]"
bg="neutral-100 dark:neutral-800"
text="lg neutral-500 dark:neutral-400"
hover:text="primary-500 dark:primary-400"
flex items-center justify-center rounded-md p-2 outline-none
transition-colors transition-transform active:scale-95
title="Image Journal"
@click="navigateToImageJournal"
>
<div class="i-solar:gallery-bold-duotone" />
</button>
<button
:class="[
'max-h-[10lh] min-h-[1lh]',
]"
bg="neutral-100 dark:neutral-800"
text="lg neutral-500 dark:neutral-400"
hover:text="red-500 dark:red-400"
flex items-center justify-center rounded-md p-2 outline-none
transition-colors transition-transform active:scale-95
@click="handleCleanupMessages"
>
<div class="i-solar:trash-bin-2-bold-duotone" />
</button>
<!-- Attach Image -->
<button
class="max-h-[10lh] min-h-[1lh]"
bg="neutral-100 dark:neutral-800"
text="lg neutral-500 dark:neutral-400"
hover:text="primary-500 dark:primary-400"
flex items-center justify-center rounded-md p-2 outline-none
transition-colors transition-transform active:scale-95
title="Attach Image"
@click="handleManualAttach"
>
<div class="i-solar:camera-add-bold-duotone" />
</button>
<input
ref="fileInput"
type="file"
accept="image/*"
class="hidden"
multiple
@change="handleFileSelect"
>
</div>
<BasicTextarea
v-model="messageInput"
:submit-on-enter="false"
:placeholder="t('stage.message')"
class="ph-no-capture [scrollbar-gutter:stable]"
text="primary-600 dark:primary-100 placeholder:primary-500 dark:placeholder:primary-200"
border="solid 2 primary-200/20 dark:primary-400/20"
bg="primary-100/50 dark:primary-900/70"
max-h="[10lh]" min-h="[1lh]"
w-full shrink-0 resize-none overflow-y-auto rounded-xl p-2 font-medium outline-none
transition="all duration-250 ease-in-out placeholder:all placeholder:duration-250 placeholder:ease-in-out"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
@keydown="handleMessageInputKeydown"
@paste-file="handleFilePaste"
/>
</div>
</template>
</ChatViewportLayout>
<!-- Image Journal Deep Link -->
<button
class="max-h-[10lh] min-h-[1lh]"
bg="neutral-100 dark:neutral-800"
text="lg neutral-500 dark:neutral-400"
hover:text="primary-500 dark:primary-400"
flex items-center justify-center rounded-md p-2 outline-none
transition-colors transition-transform active:scale-95
title="Image Journal"
@click="navigateToImageJournal"
>
<div class="i-solar:gallery-bold-duotone" />
</button>
<!-- Attach Image -->
<button
class="max-h-[10lh] min-h-[1lh]"
bg="neutral-100 dark:neutral-800"
text="lg neutral-500 dark:neutral-400"
hover:text="primary-500 dark:primary-400"
flex items-center justify-center rounded-md p-2 outline-none
transition-colors transition-transform active:scale-95
title="Attach Image"
@click="handleManualAttach"
>
<div class="i-solar:camera-add-bold-duotone" />
</button>
<input
ref="fileInput"
type="file"
accept="image/*"
class="hidden"
multiple
@change="handleFileSelect"
>
</div>
<BasicTextarea
v-model="messageInput"
:submit-on-enter="false"
:placeholder="t('stage.message')"
class="ph-no-capture [scrollbar-gutter:stable]"
text="primary-600 dark:primary-100 placeholder:primary-500 dark:placeholder:primary-200"
border="solid 2 primary-200/20 dark:primary-400/20"
bg="primary-100/50 dark:primary-900/70"
max-h="[10lh]" min-h="[1lh]"
w-full shrink-0 resize-none overflow-y-auto rounded-xl p-2 font-medium outline-none
transition="all duration-250 ease-in-out placeholder:all placeholder:duration-250 placeholder:ease-in-out"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
@keydown="handleMessageInputKeydown"
@paste-file="handleFilePaste"
/>
<!-- Shared Preview Modal -->
<JournalPreviewModal />
</div>
<!-- Shared Preview Modal -->
<JournalPreviewModal />
</template>
@@ -0,0 +1,73 @@
import { ScrollableArea } from '@proj-airi/ui'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-vue'
import { defineComponent } from 'vue'
import ChatViewportLayout from './chat-viewport-layout.vue'
import '@unocss/reset/tailwind.css'
import 'virtual:uno.css'
describe('desktop chat viewport layout', () => {
// ROOT CAUSE:
//
// The history viewport must stop above the fixed composer so the scrollbar
// belongs to messages only instead of extending beside the input controls.
it('keeps the message viewport above a fixed composer', async () => {
const TestHost = defineComponent({
components: { ChatViewportLayout, ScrollableArea },
template: `
<ChatViewportLayout style="height: 320px; width: 240px">
<template #history>
<ScrollableArea
type="always"
viewport-class="chat-history-list"
style="height: 100%; width: 100%"
>
<div style="height: 640px">Long chat history</div>
</ScrollableArea>
</template>
<template #composer>
<div style="height: 80px">Fixed composer</div>
</template>
</ChatViewportLayout>
`,
})
const screen = await render(TestHost)
const layout = screen.getByTestId('chat-viewport-layout').element() as HTMLElement
const historyLayer = screen.getByTestId('chat-history-layer').element() as HTMLElement
const composer = screen.getByTestId('chat-composer-layer').element() as HTMLElement
const history = screen.container.querySelector<HTMLElement>('.chat-history-list')
const scrollbar = screen.container.querySelector<HTMLElement>('.scrollable-area-scrollbar--vertical')
expect(history).not.toBeNull()
expect(scrollbar).not.toBeNull()
if (!history || !scrollbar)
throw new Error('Expected the chat history viewport and its custom scrollbar.')
await vi.waitFor(() => {
expect(getComputedStyle(history).paddingBottom).toBe('16px')
expect(history.scrollHeight).toBeGreaterThan(history.clientHeight)
})
const layoutRect = layout.getBoundingClientRect()
const historyRect = historyLayer.getBoundingClientRect()
const composerRect = composer.getBoundingClientRect()
expect(historyRect.top).toBe(layoutRect.top)
expect(historyRect.right).toBe(layoutRect.right)
expect(historyRect.bottom).toBe(composerRect.top)
expect(getComputedStyle(history).borderRadius).toBe('0px')
const scrollbarRect = scrollbar.getBoundingClientRect()
expect(scrollbarRect.top).toBe(historyRect.top)
expect(scrollbarRect.right).toBe(historyRect.right)
expect(scrollbarRect.bottom).toBe(historyRect.bottom)
expect(layoutRect.right - composerRect.right).toBe(16)
const composerTop = composer.getBoundingClientRect().top
history.scrollTop = 120
history.dispatchEvent(new Event('scroll'))
expect(composer.getBoundingClientRect().top).toBe(composerTop)
})
})
@@ -0,0 +1,57 @@
<template>
<div
data-testid="chat-viewport-layout"
:class="[
'chat-viewport-layout',
]"
>
<div
data-testid="chat-history-layer"
:class="[
'chat-history-layer',
]"
>
<slot name="history" />
</div>
<div
data-testid="chat-composer-layer"
:class="[
'chat-composer-layer',
]"
>
<slot name="composer" />
</div>
</div>
</template>
<style scoped>
.chat-viewport-layout {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
width: 100%;
height: 100%;
overflow: hidden;
}
.chat-history-layer {
position: relative;
min-height: 0;
}
.chat-composer-layer {
position: relative;
z-index: 20;
min-height: 0;
max-height: calc(100% - 1rem);
margin: 0 1rem 1rem;
overflow: hidden;
}
.chat-viewport-layout :deep(.chat-history-list) {
box-sizing: border-box;
border-radius: 0 !important;
padding: 1rem;
padding-bottom: 1rem;
}
</style>
@@ -8,7 +8,7 @@ import semver from 'semver'
import { useElectronAutoUpdater, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { AboutContent, BugReportDialog, createBugReportPageContext, MarkdownRenderer } from '@proj-airi/stage-ui/components'
import { useAnalytics, useBreakpoints, useBuildInfo } from '@proj-airi/stage-ui/composables'
import { Button, ContainerError, DoubleCheckButton, FieldSelect, Progress } from '@proj-airi/ui'
import { Button, ContainerError, DoubleCheckButton, FieldSelect, Progress, ScrollableArea } from '@proj-airi/ui'
import { useClipboard } from '@vueuse/core'
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { DrawerContent, DrawerDescription, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTitle } from 'vaul-vue'
@@ -417,9 +417,13 @@ onMounted(() => {
{{ t('tamagotchi.stage.about.update.dialog.description', { version: updateState.info?.version }) }}
</DialogDescription>
<div class="min-h-0 flex-1 overflow-y-auto border border-neutral-200 rounded-lg bg-neutral-50 p-4 dark:border-neutral-800 dark:bg-neutral-950/50">
<ScrollableArea
:class="['min-h-0 flex-1 border border-neutral-200 rounded-lg bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-950/50']"
:style="{ maxHeight: 'calc(85vh - 12rem)' }"
:viewport-class="['p-4']"
>
<MarkdownRenderer :content="releaseNotesContent || t('tamagotchi.stage.about.update.dialog.no-release-notes-markdown')" class="text-sm" />
</div>
</ScrollableArea>
<div class="mt-6 flex justify-end gap-3">
<Button @click="showChangelog = false">
@@ -447,9 +451,12 @@ onMounted(() => {
{{ t('tamagotchi.stage.about.update.dialog.description', { version: updateState.info?.version }) }}
</DrawerDescription>
<div class="min-h-0 flex-1 overflow-y-auto border border-neutral-200 rounded-lg bg-neutral-50 p-4 dark:border-neutral-800 dark:bg-neutral-950/50">
<ScrollableArea
:class="['min-h-0 flex-1 border border-neutral-200 rounded-lg bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-950/50']"
:viewport-class="['p-4']"
>
<MarkdownRenderer :content="releaseNotesContent || t('tamagotchi.stage.about.update.dialog.no-release-notes-markdown')" class="text-sm" />
</div>
</ScrollableArea>
<div class="mt-4 flex gap-3">
<Button block @click="showChangelog = false">
@@ -0,0 +1,13 @@
<template>
<div
data-testid="desktop-chat-page-shell"
:class="[
'h-full w-full overflow-hidden pt-11',
]"
:style="{
overflow: 'hidden',
}"
>
<slot />
</div>
</template>
@@ -0,0 +1,37 @@
import { ScrollableArea } from '@proj-airi/ui'
import { describe, expect, it } from 'vitest'
import { render } from 'vitest-browser-vue'
import { defineComponent } from 'vue'
import ChatPageShell from './chat-page-shell.vue'
describe('desktop chat page scrolling', () => {
it('leaves scrolling to the rendered chat history viewport', async () => {
const TestHost = defineComponent({
components: { ChatPageShell, ScrollableArea },
template: `
<ChatPageShell style="height: 160px; width: 240px">
<ScrollableArea data-testid="history-area" style="height: 100%">
<div style="height: 320px">Long chat history</div>
</ScrollableArea>
</ChatPageShell>
`,
})
const screen = await render(TestHost)
const shell = screen.getByTestId('desktop-chat-page-shell').element() as HTMLElement
const viewport = screen.container.querySelector<HTMLElement>('[data-reka-scroll-area-viewport]')
expect(getComputedStyle(shell).overflowY).toBe('hidden')
expect(getComputedStyle(viewport!).overflowY).toBe('scroll')
expect(viewport!.scrollHeight).toBeGreaterThan(viewport!.clientHeight)
const verticalScrollOwners = [shell, viewport].filter((element) => {
if (!element)
return false
return ['auto', 'scroll'].includes(getComputedStyle(element).overflowY)
&& element.scrollHeight > element.clientHeight
})
expect(verticalScrollOwners).toEqual([viewport])
})
})
@@ -8,6 +8,7 @@ import { useI18n } from 'vue-i18n'
import InteractiveArea from '../components/InteractiveArea.vue'
import WindowTitleBar from '../components/Window/TitleBar.vue'
import ChatPageShell from './chat-page-shell.vue'
const sessionsDrawerOpen = shallowRef(false)
const getOutputPlaybackState = defineInvoke(getSpeechBusContext(), speechOutputGetPlaybackState)
@@ -25,7 +26,7 @@ const { t } = useI18n()
</script>
<template>
<div h-full w-full pt="44px" overflow-y-scroll>
<ChatPageShell>
<WindowTitleBar
title="Chat"
icon="i-solar:chat-line-bold"
@@ -66,10 +67,10 @@ const { t } = useI18n()
</WindowTitleBar>
<InteractiveArea
class="interaction-area block"
h-full w-full p-4 transition="opacity duration-250"
h-full w-full transition="opacity duration-250"
/>
<ChatSessionsDrawer v-model="sessionsDrawerOpen" />
</div>
</ChatPageShell>
</template>
<route lang="yaml">
@@ -383,7 +383,6 @@ onUnmounted(() => {
'max-w-6xl',
'h-fit',
'sm:max-h-[80dvh]',
'overflow-y-scroll',
'relative',
]"
@patch-godot-view-state="handleGodotViewPatch"
@@ -2,6 +2,7 @@
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useBackgroundStore } from '@proj-airi/stage-ui/stores/background'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { ScrollableArea } from '@proj-airi/ui'
import { computed, ref, watch } from 'vue'
import { widgetsHideWindow, widgetsRemove } from '../../../../shared/eventa'
@@ -265,35 +266,40 @@ async function handleClose() {
</button>
</div>
<div class="custom-scrollbar flex-1 overflow-y-auto pr-1 text-[11px] space-y-4">
<div class="space-y-1">
<div class="text-[9px] text-white/30 font-bold uppercase">
Generated Prompt
<ScrollableArea
:class="['min-h-0 flex-1 text-[11px]']"
:viewport-class="['pr-1']"
>
<div :class="['space-y-4']">
<div class="space-y-1">
<div class="text-[9px] text-white/30 font-bold uppercase">
Generated Prompt
</div>
<div class="border border-white/5 rounded bg-white/5 p-2 text-white/80 leading-relaxed italic">
{{ currentImage?.prompt || prompt || 'No prompt available for this frame.' }}
</div>
</div>
<div class="border border-white/5 rounded bg-white/5 p-2 text-white/80 leading-relaxed italic">
{{ currentImage?.prompt || prompt || 'No prompt available for this frame.' }}
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="border border-white/5 rounded bg-white/5 p-2">
<div class="mb-1 text-[8px] text-white/30 font-bold uppercase">
Remix ID
<div class="grid grid-cols-2 gap-2">
<div class="border border-white/5 rounded bg-white/5 p-2">
<div class="mb-1 text-[8px] text-white/30 font-bold uppercase">
Remix ID
</div>
<div class="text-white/90">
#{{ currentImage?.remixId || remixId || '000000' }}
</div>
</div>
<div class="text-white/90">
#{{ currentImage?.remixId || remixId || '000000' }}
</div>
</div>
<div class="border border-white/5 rounded bg-white/5 p-2">
<div class="mb-1 text-[8px] text-white/30 font-bold uppercase">
Time
</div>
<div class="text-white/90">
{{ renderTime || '--.--s' }}
<div class="border border-white/5 rounded bg-white/5 p-2">
<div class="mb-1 text-[8px] text-white/30 font-bold uppercase">
Time
</div>
<div class="text-white/90">
{{ renderTime || '--.--s' }}
</div>
</div>
</div>
</div>
</div>
</ScrollableArea>
<div class="mt-auto pt-2 space-y-2">
<button
+2
View File
@@ -1,6 +1,7 @@
import { cwd } from 'node:process'
import vue from '@vitejs/plugin-vue'
import UnoCss from 'unocss/vite'
import Info from 'unplugin-info/vite'
import { playwright } from '@vitest/browser-playwright'
@@ -12,6 +13,7 @@ export default defineConfig({
plugins: [
Info(),
vue(),
UnoCss(),
],
test: {
env: loadEnv('test', cwd(), ''),
@@ -4,7 +4,7 @@ import type { Character, CreateCharacterPayload } from '@proj-airi/stage-ui/type
import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
import { useCharacterStore } from '@proj-airi/stage-ui/stores/characters'
import { CreateCharacterSchema } from '@proj-airi/stage-ui/types/character'
import { Button, FieldInput, GhostButton } from '@proj-airi/ui'
import { Button, FieldInput, GhostButton, ScrollableArea } from '@proj-airi/ui'
import {
DialogContent,
DialogOverlay,
@@ -234,7 +234,11 @@ const isOpen = computed({
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-6">
<ScrollableArea
:class="['min-h-0 flex-1']"
:style="{ maxHeight: 'calc(85vh - 8rem)' }"
:viewport-class="['p-6']"
>
<!-- Identity Tab -->
<div v-show="activeTab === 'identity'" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
@@ -286,7 +290,7 @@ const isOpen = computed({
</div>
</div>
</div>
</div>
</ScrollableArea>
</div>
<!-- Footer -->
+15
View File
@@ -79,6 +79,21 @@ Responsive screen component that calculates canvas dimensions based on breakpoin
**Props**: None | **Slots**: `default({ width, height })`
### ScrollableArea
Reka UI scroll area with shared light-mode and dark-mode scrollbar styles.
The component forwards HTML attributes to the scroll-area root.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `contentAsChild` | `boolean?` | `false` | Render the viewport content wrapper through the single default slot child |
| `orientation` | `'vertical' \| 'horizontal' \| 'both'` | `'vertical'` | Scrollbar orientations to render |
| `type` | `ScrollAreaRootProps['type']?` | `'auto'` | Reka UI scrollbar visibility behavior |
| `viewportClass` | `string \| string[]?` | — | Classes for the Reka UI viewport |
**Slots**: `default`
**Exposed**: `viewport` (the native scroll owner. Reka UI hides its native scrollbar and renders the configured custom track.)
### Skeleton
Loading placeholder with animation.
@@ -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>