diff --git a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.browser.test.ts b/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.browser.test.ts index 913685e1c..ee365b208 100644 --- a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.browser.test.ts +++ b/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.browser.test.ts @@ -4,7 +4,6 @@ import type { Component } from 'vue' import SharedInteractiveArea from '@proj-airi/stage-layouts/components/Layouts/InteractiveArea' import MobileInteractiveArea from '@proj-airi/stage-layouts/components/Layouts/MobileInteractiveArea' -import ChatArea from '@proj-airi/stage-layouts/components/Widgets/ChatArea' import { PiniaColada } from '@pinia/colada' import { useThreeViewControl } from '@proj-airi/stage-ui-three' @@ -109,6 +108,62 @@ async function attachImages(screen: Awaited>['scre }) } +function dispatchHorizontalPan(element: HTMLElement, deltaX = 60) { + element.dispatchEvent(new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaX, + deltaY: 2, + })) +} + +async function expectElectronReplyBubble(screen: Awaited>['screen']) { + await vi.waitFor(() => { + expect(screen.container.querySelector('[data-swipeable]')).not.toBeNull() + }) + + const input = screen.getByRole('textbox').element() as HTMLTextAreaElement + const bubble = input.parentElement + const swipeSurface = screen.container.querySelector('[data-swipeable]') + expect(bubble).not.toBeNull() + expect(swipeSurface).not.toBeNull() + if (!bubble || !swipeSurface) + throw new Error('Expected the message input bubble and a swipe surface.') + + const collapsedHeight = bubble.getBoundingClientRect().height + dispatchHorizontalPan(swipeSurface) + + await vi.waitFor(() => { + const cancelButton = bubble.querySelector('[aria-label="stage.chat.reply.cancel"]') + expect(cancelButton?.parentElement?.getAttribute('aria-hidden')).toBe('false') + expect(bubble.getBoundingClientRect().height).toBeGreaterThan(collapsedHeight) + }) + + expect(getComputedStyle(input).backgroundColor).toBe('rgba(0, 0, 0, 0)') + expect(getComputedStyle(bubble).backgroundColor).not.toBe('rgba(0, 0, 0, 0)') + + const cancelButton = bubble.querySelector('[aria-label="stage.chat.reply.cancel"]') + const replyTransition = cancelButton?.parentElement?.parentElement + expect(replyTransition).not.toBeNull() + expect(cancelButton).not.toBeNull() + if (!replyTransition || !cancelButton) + throw new Error('Expected the reply transition and cancel button.') + + expect(Number.parseFloat(getComputedStyle(replyTransition).transitionDuration)).toBeGreaterThan(0) + const expandedHeight = bubble.getBoundingClientRect().height + cancelButton.click() + await nextTick() + expect(cancelButton.parentElement?.getAttribute('aria-hidden')).toBe('true') + expect(bubble.getBoundingClientRect().height).toBeGreaterThan(collapsedHeight) + expect(bubble.getBoundingClientRect().height).toBeCloseTo(expandedHeight, 0) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(bubble.getBoundingClientRect().height).toBeGreaterThan(collapsedHeight) + expect(bubble.getBoundingClientRect().height).toBeLessThan(expandedHeight) + await vi.waitFor(() => { + expect(bubble.getBoundingClientRect().height).toBeCloseTo(collapsedHeight, 0) + }) +} + describe('interactive area synchronized state', () => { it('opens mobile settings from an icon-only header and restores focus', async () => { await page.viewport(390, 844) @@ -154,7 +209,7 @@ describe('interactive area synchronized state', () => { }) it('removes the clear-messages action from desktop chat surfaces', async () => { - for (const component of [InteractiveArea, SharedInteractiveArea, ChatArea]) { + for (const component of [InteractiveArea, SharedInteractiveArea]) { const { screen } = await renderArea(component) expect(screen.container.querySelector('[class*="trash-bin-2-bold-duotone"]')).toBeNull() screen.unmount() @@ -344,12 +399,13 @@ describe('interactive area synchronized state', () => { await screen.getByTestId('view-controls-close-button').click() }) - it('keeps the empty mobile input compact and aligns the one-line send action', async () => { + it('keeps the empty mobile input compact and aligns the send action with its bubble', async () => { // ROOT CAUSE: // // The hierarchy redesign removed the input bubble's compact maximum width. // The 40px bubble also top-aligned its 32px textarea while the send action - // aligned to the bottom of the same row. + // aligned to the bottom of the same row. The reply container now owns the + // visible border, so the action aligns with the bubble instead of its inset textarea. await page.viewport(390, 844) const { screen } = await renderArea(MobileInteractiveArea) const composer = screen.getByTestId('mobile-message-composer').element() @@ -363,12 +419,13 @@ describe('interactive area synchronized state', () => { expect(Math.round(bubble.getBoundingClientRect().width)).toBe(Math.round(composerContentWidth * 0.7)) await userEvent.fill(input, 'hi') - const send = composer.querySelector('button') - expect(send).not.toBeNull() + const send = screen.getByRole('button', { name: 'stage.chat.actions.send' }).element() await expect.poll(() => input.getBoundingClientRect().height).toBe(32) - expect(send!.getBoundingClientRect().height).toBe(32) - expect(input.getBoundingClientRect().top).toBe(send!.getBoundingClientRect().top) - expect(input.getBoundingClientRect().bottom).toBe(send!.getBoundingClientRect().bottom) + expect(send.getBoundingClientRect().height).toBe(32) + expect(bubble.getBoundingClientRect().bottom).toBe(send.getBoundingClientRect().bottom) + expect(input.getBoundingClientRect().bottom).toBe( + bubble.getBoundingClientRect().bottom - Number.parseFloat(getComputedStyle(bubble).borderBottomWidth), + ) }) it('closes mobile settings before requesting sign-in', async () => { @@ -401,6 +458,82 @@ describe('interactive area synchronized state', () => { await expect.element(screen.getByRole('dialog', { name: 'stage.mobile-tools.hearing' })).not.toBeInTheDocument() }) + it('expands the Electron input bubble around a reply preview', async () => { + const { chatSession, screen } = await renderArea() + chatSession.$patch((state) => { + state.sessionMessages['session-b'] = [{ id: 'reply-target', role: 'user', content: 'Reply target' }] + }) + + await expectElectronReplyBubble(screen) + }) + + // https://github.com/moeru-ai/airi/pull/2489#discussion_r3966523188 + // ROOT CAUSE: + // + // Clearing a reply hid the still-mounted preview without moving focus from + // its cancel button. Keyboard focus then remained in an aria-hidden subtree. + // + // Each composer owner now clears the reply and restores focus to its input. + it('restores composer focus after a keyboard user cancels a reply', async () => { + const { chatSession, screen } = await renderArea() + chatSession.$patch((state) => { + state.sessionMessages['session-b'] = [{ id: 'reply-target', role: 'user', content: 'Reply target' }] + }) + + await vi.waitFor(() => { + expect(screen.container.querySelector('[data-swipeable]')).not.toBeNull() + }) + const swipeable = screen.container.querySelector('[data-swipeable]') + if (!swipeable) + throw new Error('Expected a swipeable message.') + + dispatchHorizontalPan(swipeable) + await vi.waitFor(() => { + const button = screen.container.querySelector('[aria-label="stage.chat.reply.cancel"]') + expect(button?.parentElement?.getAttribute('aria-hidden')).toBe('false') + }) + const cancelButton = screen.container.querySelector('[aria-label="stage.chat.reply.cancel"]') + if (!cancelButton) + throw new Error('Expected a reply cancel button.') + cancelButton.focus() + expect(document.activeElement).toBe(cancelButton) + + await userEvent.keyboard('{Enter}') + + await expect.element(screen.getByRole('textbox')).toHaveFocus() + expect(cancelButton.closest('[aria-hidden="true"]')).not.toBeNull() + }) + + it('sends the Electron reply as a native message relation', async () => { + const { chat, chatSession, screen } = await renderArea() + chatSession.$patch((state) => { + state.sessionMessages['session-b'] = [{ id: 'reply-target', role: 'user', content: 'Reply target' }] + }) + const send = vi.spyOn(chat, 'send').mockResolvedValueOnce({ messages: [], sessionId: 'session-b' }) + + await vi.waitFor(() => { + expect(screen.container.querySelector('[data-swipeable]')).not.toBeNull() + }) + const swipeRoot = screen.container.querySelector('[data-swipeable]') + if (!swipeRoot) + throw new Error('Expected a message swipe root.') + + dispatchHorizontalPan(swipeRoot) + await vi.waitFor(() => { + const cancelButton = screen.container.querySelector('[aria-label="stage.chat.reply.cancel"]') + expect(cancelButton?.parentElement?.getAttribute('aria-hidden')).toBe('false') + }) + await submitDraft(screen, 'My answer') + + await vi.waitFor(() => { + expect(send).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: 'session-b', + text: 'My answer', + replyToMessageId: 'reply-target', + })) + }) + }) + // https://github.com/moeru-ai/airi/pull/2399 it('keeps the input visible when a short window contains many attachments', async () => { // ROOT CAUSE: @@ -470,6 +603,122 @@ describe('interactive area synchronized state', () => { expect(scrollOwners).toEqual([viewport]) }) + it('keeps the history scrollport behind the floating composer', async () => { + const { chatSession, screen } = await renderArea() + const layout = screen.getByTestId('chat-viewport-layout').element() as HTMLElement + layout.style.height = '320px' + layout.style.width = '320px' + const history = screen.getByTestId('chat-history-layer').element() as HTMLElement + const composer = screen.getByTestId('chat-composer-layer').element() as HTMLElement + const viewport = screen.container.querySelector('.chat-history-list') + expect(viewport).not.toBeNull() + if (!viewport) + throw new Error('Expected the production chat history viewport.') + + chatSession.$patch((state) => { + state.sessionMessages['session-b'] = Array.from({ length: 100 }, (_, index) => ({ + id: `overlay-message-${index}`, + role: 'user', + content: `Overlay message ${index}`, + createdAt: index, + })) + }) + + await vi.waitFor(() => { + expect(viewport.isConnected).toBe(true) + expect(viewport.scrollHeight).toBeGreaterThan(viewport.clientHeight) + expect(viewport.textContent).toContain('Overlay message 99') + }) + + const layoutRect = layout.getBoundingClientRect() + const historyRect = history.getBoundingClientRect() + const composerRect = composer.getBoundingClientRect() + expect(historyRect.top).toBeCloseTo(layoutRect.top, 0) + expect(historyRect.bottom).toBeCloseTo(layoutRect.bottom, 0) + expect(composerRect.top).toBeLessThan(historyRect.bottom) + + await vi.waitFor(() => { + const bottomPadding = Number.parseFloat(getComputedStyle(viewport).paddingBottom) + const composerSpacerHeight = Number.parseFloat(getComputedStyle(viewport, '::after').height) + expect(bottomPadding).toBeCloseTo(16, 0) + expect(composerSpacerHeight).toBeGreaterThan(composerRect.height) + }) + + const collapsedComposerHeight = composer.getBoundingClientRect().height + const collapsedComposerSpacerHeight = Number.parseFloat(getComputedStyle(viewport, '::after').height) + const swipeSurface = screen.container.querySelector('[data-swipeable]') + expect(swipeSurface).not.toBeNull() + if (!swipeSurface) + throw new Error('Expected a message swipe surface.') + + dispatchHorizontalPan(swipeSurface) + + await vi.waitFor(() => { + const cancelButton = composer.querySelector('[aria-label="stage.chat.reply.cancel"]') + expect(cancelButton?.parentElement?.getAttribute('aria-hidden')).toBe('false') + expect(composer.getBoundingClientRect().height).toBeGreaterThan(collapsedComposerHeight) + expect(Number.parseFloat(getComputedStyle(viewport, '::after').height)).toBeGreaterThan(collapsedComposerSpacerHeight) + }) + + viewport.scrollTop = 241 + viewport.dispatchEvent(new Event('scroll')) + await new Promise(resolve => setTimeout(resolve, 50)) + + let messageBehindComposer: HTMLElement | undefined + await vi.waitFor(() => { + const currentComposerRect = composer.getBoundingClientRect() + messageBehindComposer = Array.from(screen.container.querySelectorAll('.chat-message-item')) + .filter(message => message.textContent?.includes('Overlay message')) + .find((message) => { + const messageRect = message.getBoundingClientRect() + return messageRect.top < currentComposerRect.bottom && messageRect.bottom > currentComposerRect.top + }) + expect(messageBehindComposer).toBeDefined() + }) + if (!messageBehindComposer) + throw new Error('Expected a mounted message behind the composer.') + + const targetText = messageBehindComposer.textContent + const positionedScrollTop = viewport.scrollTop + expect(positionedScrollTop).toBeGreaterThan(0) + let targetWasUnmounted = false + const targetObserver = new MutationObserver(() => { + if (!messageBehindComposer?.isConnected) + targetWasUnmounted = true + }) + targetObserver.observe(viewport, { childList: true, subtree: true }) + + viewport.scrollTop = positionedScrollTop + 1 + viewport.dispatchEvent(new Event('scroll')) + await new Promise(resolve => setTimeout(resolve, 220)) + + expect(messageBehindComposer.isConnected).toBe(true) + expect(messageBehindComposer.textContent).toBe(targetText) + + viewport.scrollTop = positionedScrollTop + viewport.dispatchEvent(new Event('scroll')) + await new Promise(resolve => setTimeout(resolve, 220)) + + expect(messageBehindComposer.isConnected).toBe(true) + expect(messageBehindComposer.textContent).toBe(targetText) + + viewport.scrollTop = positionedScrollTop - 1 + viewport.dispatchEvent(new Event('scroll')) + await new Promise(resolve => setTimeout(resolve, 220)) + + expect(messageBehindComposer.isConnected).toBe(true) + expect(messageBehindComposer.textContent).toBe(targetText) + + viewport.scrollTop = positionedScrollTop + viewport.dispatchEvent(new Event('scroll')) + await new Promise(resolve => setTimeout(resolve, 220)) + targetObserver.disconnect() + + expect(targetWasUnmounted).toBe(false) + expect(messageBehindComposer.isConnected).toBe(true) + expect(messageBehindComposer.textContent).toBe(targetText) + }) + // 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: @@ -644,7 +893,7 @@ describe('interactive area synchronized state', () => { }) it('does not restore a deleted-session draft in the shared chat widget', async () => { - const { chat, screen } = await renderArea(ChatArea) + const { chat, screen } = await renderArea(SharedInteractiveArea) let rejectSend: ((error: Error) => void) | undefined vi.spyOn(chat, 'send').mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectSend = reject diff --git a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue b/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue index d570eed70..86a75d279 100644 --- a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue +++ b/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue @@ -1,10 +1,12 @@ + + diff --git a/apps/stage-tamagotchi/src/renderer/components/chat-viewport-layout.browser.test.ts b/apps/stage-tamagotchi/src/renderer/components/chat-viewport-layout.browser.test.ts index 5439433be..f913f3f5a 100644 --- a/apps/stage-tamagotchi/src/renderer/components/chat-viewport-layout.browser.test.ts +++ b/apps/stage-tamagotchi/src/renderer/components/chat-viewport-layout.browser.test.ts @@ -1,19 +1,29 @@ +import type { ChatHistoryItem, StreamingAssistantMessage } from '@proj-airi/stage-ui/types/chat' + +import en from '@proj-airi/i18n/locales/en' + +import { ChatHistory } from '@proj-airi/stage-ui/components' import { ScrollableArea } from '@proj-airi/ui' import { describe, expect, it, vi } from 'vitest' import { render } from 'vitest-browser-vue' -import { defineComponent } from 'vue' +import { defineComponent, shallowRef } from 'vue' +import { createI18n } from 'vue-i18n' import ChatViewportLayout from './chat-viewport-layout.vue' import '@unocss/reset/tailwind.css' import 'virtual:uno.css' +function createEnglishI18n() { + return createI18n({ + legacy: false, + locale: 'en', + messages: { en }, + }) +} + 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 () => { + it('keeps history behind the fixed translucent composer', async () => { const TestHost = defineComponent({ components: { ChatViewportLayout, ScrollableArea }, template: ` @@ -48,6 +58,7 @@ describe('desktop chat viewport layout', () => { await vi.waitFor(() => { expect(getComputedStyle(history).paddingBottom).toBe('16px') + expect(Number.parseFloat(getComputedStyle(history, '::after').height)).toBeGreaterThan(80) expect(history.scrollHeight).toBeGreaterThan(history.clientHeight) }) @@ -56,7 +67,8 @@ describe('desktop chat viewport layout', () => { const composerRect = composer.getBoundingClientRect() expect(historyRect.top).toBe(layoutRect.top) expect(historyRect.right).toBe(layoutRect.right) - expect(historyRect.bottom).toBe(composerRect.top) + expect(historyRect.bottom).toBe(layoutRect.bottom) + expect(composerRect.top).toBeLessThan(historyRect.bottom) expect(getComputedStyle(history).borderRadius).toBe('0px') const scrollbarRect = scrollbar.getBoundingClientRect() @@ -70,4 +82,133 @@ describe('desktop chat viewport layout', () => { history.dispatchEvent(new Event('scroll')) expect(composer.getBoundingClientRect().top).toBe(composerTop) }) + + // https://github.com/moeru-ai/airi/pull/2489#discussion_r3967818100 + // ROOT CAUSE: + // + // The viewport spacer increases the native scroll range, but Virtua calculates + // end alignment from item offsets that do not include that spacer. An automatic + // tail scroll therefore leaves the final message behind the fixed composer. + // + // The Virtua scroll adapter must add the composer inset to end-aligned requests. + it('keeps an automatically scrolled long-history tail above the fixed composer', async () => { + const messages = shallowRef(Array.from({ length: 40 }, (_, index) => ({ + id: `message-${index}`, + role: 'user', + content: `Message ${index}`, + }))) + const sending = shallowRef(false) + const streamingMessage = shallowRef() + const TestHost = defineComponent({ + components: { ChatHistory, ChatViewportLayout }, + setup() { + return { messages, sending, streamingMessage } + }, + template: ` + + + + + `, + }) + + const screen = await render(TestHost, { + global: { + plugins: [createEnglishI18n()], + }, + }) + const composer = screen.getByTestId('chat-composer-layer').element() as HTMLElement + + async function expectVisibleTail(text: string) { + await vi.waitFor(() => { + const mountedMessages = screen.container.querySelectorAll('.chat-message-item') + const finalMessage = [...mountedMessages].find(message => message.textContent?.includes(text)) + expect(finalMessage).not.toBeUndefined() + expect(finalMessage!.getBoundingClientRect().bottom).toBeLessThanOrEqual(composer.getBoundingClientRect().top + 1) + }) + } + + await expectVisibleTail('Message 39') + + messages.value = [...messages.value, { + id: 'message-40', + role: 'user', + content: 'Appended tail', + }] + await expectVisibleTail('Appended tail') + + sending.value = true + streamingMessage.value = { + id: 'streaming-tail', + role: 'assistant', + content: 'Streaming tail', + slices: [{ type: 'text', text: 'Streaming tail' }], + tool_results: [], + } + await expectVisibleTail('Streaming tail') + + const expandedStreamText = 'Expanded streaming tail '.repeat(12) + streamingMessage.value = { + id: 'streaming-tail', + role: 'assistant', + content: expandedStreamText, + slices: [{ type: 'text', text: expandedStreamText }], + tool_results: [], + } + await expectVisibleTail('Expanded streaming tail') + }) + + // ROOT CAUSE: + // + // Virtua bottom-aligns a short list with transforms. Transforms do not add to + // the viewport's scroll size, so the composer spacer cannot reveal a message + // that the transform placed behind the composer. + // + // The short-list transform must reserve the composer inset. Long histories + // still use the viewport spacer and can scroll behind the translucent layer. + it('keeps a short virtualized history above the fixed composer', async () => { + const messages: ChatHistoryItem[] = [{ + id: 'short-history-message', + role: 'user', + content: 'Short message', + }] + const TestHost = defineComponent({ + components: { ChatHistory, ChatViewportLayout }, + setup() { + return { messages } + }, + template: ` + + + + + `, + }) + + const screen = await render(TestHost, { + global: { + plugins: [createEnglishI18n()], + }, + }) + const composer = screen.getByTestId('chat-composer-layer').element() as HTMLElement + + await vi.waitFor(() => { + const message = screen.container.querySelector('.chat-message-item') + expect(message).not.toBeNull() + expect(message!.getBoundingClientRect().bottom).toBeLessThanOrEqual(composer.getBoundingClientRect().top) + }) + }) }) diff --git a/apps/stage-tamagotchi/src/renderer/components/chat-viewport-layout.vue b/apps/stage-tamagotchi/src/renderer/components/chat-viewport-layout.vue index d59439079..071ae77a9 100644 --- a/apps/stage-tamagotchi/src/renderer/components/chat-viewport-layout.vue +++ b/apps/stage-tamagotchi/src/renderer/components/chat-viewport-layout.vue @@ -1,9 +1,26 @@ + +