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 -->