{{ t('settings.pages.providers.provider.comfyui.settings.upload.fields_exposed', { count: totalExposed }) }}
diff --git a/packages/stage-ui/src/components/data-pane/pane.vue b/packages/stage-ui/src/components/data-pane/pane.vue
index 4823c6cab..05e3b5960 100644
--- a/packages/stage-ui/src/components/data-pane/pane.vue
+++ b/packages/stage-ui/src/components/data-pane/pane.vue
@@ -1,11 +1,13 @@
-
-
+
diff --git a/packages/stage-ui/src/components/scenarios/chat/JournalPreviewModal.vue b/packages/stage-ui/src/components/scenarios/chat/JournalPreviewModal.vue
index 38dc47101..7a4aa22ee 100644
--- a/packages/stage-ui/src/components/scenarios/chat/JournalPreviewModal.vue
+++ b/packages/stage-ui/src/components/scenarios/chat/JournalPreviewModal.vue
@@ -1,4 +1,5 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/stage-ui/src/components/scenarios/chat/components/history.browser.test.ts b/packages/stage-ui/src/components/scenarios/chat/components/history.browser.test.ts
index 1602e89bb..87c42ca62 100644
--- a/packages/stage-ui/src/components/scenarios/chat/components/history.browser.test.ts
+++ b/packages/stage-ui/src/components/scenarios/chat/components/history.browser.test.ts
@@ -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
('.chat-history-list')
+ expect(history).not.toBeNull()
+ if (!history)
+ throw new Error('Expected a chat history viewport.')
+
+ const track = screen.container.querySelector('.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('.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'))
diff --git a/packages/stage-ui/src/components/scenarios/chat/components/history.vue b/packages/stage-ui/src/components/scenarios/chat/components/history.vue
index dbc33e1c9..bba2a332c 100644
--- a/packages/stage-ui/src/components/scenarios/chat/components/history.vue
+++ b/packages/stage-ui/src/components/scenarios/chat/components/history.vue
@@ -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('chatHistory')
+const scrollContainerRef = useTemplateRef>('scroll-container')
+const chatHistoryRef = computed(() => scrollContainerRef.value?.viewport ?? null)
const virtualizerRef = useTemplateRef('virtualizer')
const { scrollToIndex } = useVirtualizerScroll(virtualizerRef)
@@ -134,20 +140,17 @@ function emitToolCallRerun(
-
-
+
-
-
diff --git a/packages/stage-ui/src/components/scenarios/chat/components/scrollable-area.browser.test.ts b/packages/stage-ui/src/components/scenarios/chat/components/scrollable-area.browser.test.ts
new file mode 100644
index 000000000..429d9b2a6
--- /dev/null
+++ b/packages/stage-ui/src/components/scenarios/chat/components/scrollable-area.browser.test.ts
@@ -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>()
+ const TestHost = defineComponent({
+ components: { ScrollableArea },
+ setup: () => ({ area }),
+ template: `
+
+ Scrollable content
+
+ `,
+ })
+
+ const screen = await render(TestHost)
+ const viewport = screen.container.querySelector('[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: `
+
+ Two-axis content
+
+ `,
+ })
+
+ const screen = await render(TestHost)
+ const viewport = screen.container.querySelector('[data-reka-scroll-area-viewport]')
+ const verticalTrack = screen.container.querySelector('[data-orientation="vertical"]')
+ const horizontalTrack = screen.container.querySelector('[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: `
+
+ Two-axis content
+
+ `,
+ })
+
+ const screen = await render(TestHost)
+ const viewport = screen.container.querySelector('[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: `
+
+ Tall preview
+
+ `,
+ })
+
+ const screen = await render(TestHost)
+ const viewport = screen.container.querySelector('[data-reka-scroll-area-viewport]')
+
+ expect(viewport?.clientHeight).toBe(120)
+ expect(viewport?.scrollHeight).toBe(240)
+ })
+})
diff --git a/packages/stage-ui/src/components/scenarios/chat/components/sessions-dialog.browser.test.ts b/packages/stage-ui/src/components/scenarios/chat/components/sessions-dialog.browser.test.ts
index 85f6cbdba..21167397a 100644
--- a/packages/stage-ui/src/components/scenarios/chat/components/sessions-dialog.browser.test.ts
+++ b/packages/stage-ui/src/components/scenarios/chat/components/sessions-dialog.browser.test.ts
@@ -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('[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')
diff --git a/packages/stage-ui/src/components/scenarios/chat/components/sessions-dialog.vue b/packages/stage-ui/src/components/scenarios/chat/components/sessions-dialog.vue
index 091a9cb76..adc15f908 100644
--- a/packages/stage-ui/src/components/scenarios/chat/components/sessions-dialog.vue
+++ b/packages/stage-ui/src/components/scenarios/chat/components/sessions-dialog.vue
@@ -1,6 +1,7 @@
-
-
-
-
- {{ t('settings.model-select.select-model.button') }}
-
-
- .zip
-
-
- .vrm
-
-
- .pmx/.pmd
-
-
-
- {{ t('settings.model-select.panel-callout.model-type-example') }}
-
-
- {{ t('settings.tachie.archive-description') }}
-
-
-
-
-
-
-
+
+
+
+
+ {{ t('settings.model-select.select-model.button') }}
+
+
+ .zip
+
+
+ .vrm
+
+
+ .pmx/.pmd
+
+
+
+ {{ t('settings.model-select.panel-callout.model-type-example') }}
+
+
+ {{ t('settings.tachie.archive-description') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
diff --git a/packages/ui/src/components/layouts/index.ts b/packages/ui/src/components/layouts/index.ts
index 3a28422b5..eaeb62d8e 100644
--- a/packages/ui/src/components/layouts/index.ts
+++ b/packages/ui/src/components/layouts/index.ts
@@ -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'
diff --git a/packages/ui/src/components/layouts/scrollable-area.vue b/packages/ui/src/components/layouts/scrollable-area.vue
new file mode 100644
index 000000000..bddaeb1a5
--- /dev/null
+++ b/packages/ui/src/components/layouts/scrollable-area.vue
@@ -0,0 +1,147 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/misc/container-error.vue b/packages/ui/src/components/misc/container-error.vue
index fd269020b..1f4dc2677 100644
--- a/packages/ui/src/components/misc/container-error.vue
+++ b/packages/ui/src/components/misc/container-error.vue
@@ -1,8 +1,8 @@