fix(ui): standardize scrollable areas (#2399)
This commit is contained in:
@@ -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)"
|
||||
>
|
||||
×
|
||||
</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)"
|
||||
>
|
||||
×
|
||||
</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
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user