mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-30 17:07:23 +08:00
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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
+95
@@ -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)
|
||||
})
|
||||
})
|
||||
+130
@@ -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>
|
||||
|
||||
+106
@@ -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)
|
||||
})
|
||||
})
|
||||
+31
-5
@@ -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>
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+88
-86
@@ -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
|
||||
|
||||
+3
-3
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user