feat(stage-layouts): simplify mobile stage controls (#2472)

Signed-off-by: RainbowBird <git@luoling.moe>
This commit is contained in:
RainbowBird
2026-09-06 16:28:01 +00:00
committed by GitHub
parent b1170ea8ca
commit 836941fda8
31 changed files with 1117 additions and 352 deletions
-2
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import Header from '@proj-airi/stage-layouts/components/Layouts/Header.vue'
import InteractiveArea from '@proj-airi/stage-layouts/components/Layouts/InteractiveArea.vue'
import MobileHeader from '@proj-airi/stage-layouts/components/Layouts/MobileHeader.vue'
import MobileInteractiveArea from '@proj-airi/stage-layouts/components/Layouts/MobileInteractiveArea.vue'
import workletUrl from '@proj-airi/stage-ui/workers/vad/process.worklet?worker&url'
@@ -200,7 +199,6 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
<!-- header -->
<div class="px-0 py-1 md:px-3 md:py-3" w-full gap-2>
<Header class="hidden md:flex" />
<MobileHeader class="flex md:hidden" />
</div>
<!-- page -->
<div relative flex="~ 1 row gap-y-0 gap-x-2 <md:col" min-h-0>
@@ -1,3 +1,4 @@
import type { AuthorizationHandler } from '@proj-airi/stage-ui/libs/auth'
import type { ChatSessionMeta } from '@proj-airi/stage-ui/types/chat-session'
import type { Component } from 'vue'
@@ -6,13 +7,14 @@ import MobileInteractiveArea from '@proj-airi/stage-layouts/components/Layouts/M
import ChatArea from '@proj-airi/stage-layouts/components/Widgets/ChatArea'
import { PiniaColada } from '@pinia/colada'
import { browserAuthorizationHandler, registerAuthorizationHandler } from '@proj-airi/stage-ui/libs/auth'
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
import { createPinia } from 'pinia'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-vue'
import { userEvent } from 'vitest/browser'
import { page, userEvent } from 'vitest/browser'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { createMemoryHistory, createRouter } from 'vue-router'
@@ -102,6 +104,132 @@ async function attachImages(screen: Awaited<ReturnType<typeof renderArea>>['scre
}
describe('interactive area synchronized state', () => {
it('opens mobile settings from an icon-only header and restores focus', async () => {
await page.viewport(390, 844)
const { screen } = await renderArea(MobileInteractiveArea)
const trigger = screen.getByRole('button', { name: 'stage.mobile-tools.title', exact: true })
const bounds = trigger.element().getBoundingClientRect()
expect(bounds.width).toBeGreaterThanOrEqual(44)
expect(bounds.height).toBeGreaterThanOrEqual(44)
expect(bounds.right).toBeLessThanOrEqual(390)
expect(bounds.left).toBeGreaterThan(300)
expect(bounds.top).toBeLessThan(40)
expect(trigger.element().textContent?.trim()).toBe('')
await expect.element(screen.getByTestId('speech-mute-button')).not.toBeInTheDocument()
await trigger.click()
await expect.element(screen.getByRole('dialog', { name: 'stage.mobile-tools.title' })).toBeVisible()
await expect.element(screen.getByText('stage.mobile-tools.sign-in', { exact: true })).toBeVisible()
const account = screen.getByRole('button', { name: 'stage.mobile-tools.sign-in stage.mobile-tools.account-description' }).element()
const drawerTitle = screen.getByRole('heading', { name: 'stage.mobile-tools.title' }).element()
const accountContent = account.querySelector<HTMLElement>('.basic-button-content')
// ROOT CAUSE:
//
// The account row relied on scoped descendant CSS to stretch
// BasicButton's content wrapper. The combined browser bundle could leave
// that wrapper at its content width, centering the label inward. Comparing
// text coordinates was also unstable while the drawer portal animated, so
// assert the owned row and content geometry directly.
expect(accountContent).not.toBeNull()
await expect.poll(() => getComputedStyle(account).paddingLeft).toBe('0px')
expect(account.getBoundingClientRect().left).toBe(drawerTitle.getBoundingClientRect().left)
expect(accountContent!.getBoundingClientRect().width).toBe(account.clientWidth)
expect(account.getBoundingClientRect().height).toBe(56)
expect(account.querySelector('[data-avatar-fallback], [data-avatar-image]')).toBeNull()
await expect.element(screen.getByText('stage.mobile-tools.cleanup', { exact: true })).not.toBeInTheDocument()
await expect.element(screen.getByRole('switch', { name: 'stage.mobile-tools.character-voice' })).toBeVisible()
const voice = screen.getByRole('switch', { name: 'stage.mobile-tools.character-voice' })
const before = voice.element().getAttribute('aria-checked')
await voice.click()
await expect.element(voice).toHaveAttribute('aria-checked', before === 'true' ? 'false' : 'true')
await expect.element(screen.getByRole('button', { name: 'Close', exact: true })).not.toBeInTheDocument()
await userEvent.keyboard('{Escape}')
await expect.element(trigger).toHaveFocus()
})
it('removes the clear-messages action from desktop chat surfaces', async () => {
for (const component of [InteractiveArea, SharedInteractiveArea, ChatArea]) {
const { screen } = await renderArea(component)
expect(screen.container.querySelector('[class*="trash-bin-2-bold-duotone"]')).toBeNull()
screen.unmount()
}
})
it('places the conversation selector opposite settings in the mobile header', async () => {
await page.viewport(390, 844)
const { screen } = await renderArea(MobileInteractiveArea)
const composer = screen.getByTestId('mobile-message-composer').element()
const conversations = screen.getByTestId('conversation-selector-button').element()
const bounds = conversations.getBoundingClientRect()
const settingsBounds = screen.getByTestId('mobile-settings-button').element().getBoundingClientRect()
expect(composer.contains(conversations)).toBe(false)
expect(bounds.left).toBe(12)
expect(bounds.top).toBe(settingsBounds.top)
expect(bounds.width).toBe(44)
expect(bounds.height).toBe(44)
expect(bounds.left).toBe(390 - settingsBounds.right)
expect(conversations.textContent?.trim()).toBe('')
await screen.getByTestId('conversation-selector-button').click()
await expect.element(screen.getByRole('dialog')).toBeVisible()
})
it('keeps the empty mobile input compact and aligns the one-line send action', 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.
await page.viewport(390, 844)
const { screen } = await renderArea(MobileInteractiveArea)
const composer = screen.getByTestId('mobile-message-composer').element()
const bubble = screen.getByTestId('mobile-input-bubble').element()
const input = screen.getByRole('textbox').element()
const composerStyle = getComputedStyle(composer)
const composerContentWidth = composer.clientWidth
- Number.parseFloat(composerStyle.paddingLeft)
- Number.parseFloat(composerStyle.paddingRight)
expect(Math.round(bubble.getBoundingClientRect().width)).toBe(Math.round(composerContentWidth * 0.7))
await userEvent.fill(input, 'hi')
const send = composer.querySelector<HTMLButtonElement>('button')
expect(send).not.toBeNull()
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)
})
it('closes mobile settings before requesting sign-in', async () => {
let openDialogAtSignIn = true
const authorize = vi.fn<AuthorizationHandler>(async () => {
openDialogAtSignIn = document.querySelector('[role="dialog"][data-state="open"]') !== null
})
registerAuthorizationHandler(authorize)
try {
const { screen } = await renderArea(MobileInteractiveArea)
await screen.getByTestId('mobile-settings-button').click()
await screen.getByRole('button', { name: 'stage.mobile-tools.sign-in stage.mobile-tools.account-description' }).click()
await expect.poll(() => authorize.mock.calls.length).toBe(1)
expect(openDialogAtSignIn).toBe(false)
await expect.element(screen.getByRole('dialog', { name: 'stage.mobile-tools.title' })).not.toBeInTheDocument()
}
finally {
registerAuthorizationHandler(browserAuthorizationHandler)
}
})
it('returns from hearing to mobile settings without stacked dialogs', async () => {
const { screen } = await renderArea(MobileInteractiveArea)
await screen.getByTestId('mobile-settings-button').click()
await screen.getByRole('button', { name: 'stage.mobile-tools.hearing' }).click()
await expect.element(screen.getByRole('dialog', { name: 'stage.mobile-tools.hearing' })).toBeVisible()
await expect.element(screen.getByRole('dialog', { name: 'stage.mobile-tools.title' })).not.toBeInTheDocument()
await userEvent.keyboard('{Escape}')
await expect.element(screen.getByRole('dialog', { name: 'stage.mobile-tools.title' })).toBeVisible()
await expect.element(screen.getByRole('dialog', { name: 'stage.mobile-tools.hearing' })).not.toBeInTheDocument()
})
// https://github.com/moeru-ai/airi/pull/2399
it('keeps the input visible when a short window contains many attachments', async () => {
// ROOT CAUSE:
@@ -63,7 +63,6 @@ const sendModeLabels = computed<Record<SendMode, string>>(() => ({
const {
trackChatMessageDeleted,
trackChatMessageRetried,
trackChatMessagesCleared,
} = useAnalytics()
const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton()
@@ -255,15 +254,6 @@ async function handleToolCallRerun(payload: { message: ChatHistoryItem, index: n
args: payload.args,
})
}
async function handleCleanupMessages() {
const messageCount = messages.value.filter(message => message.role !== 'system').length
await chatStore.cleanup(chatSession.activeSessionId)
trackChatMessagesCleared({
source: 'chat_controls',
message_count: messageCount,
})
}
</script>
<template>
@@ -410,20 +400,6 @@ async function handleCleanupMessages() {
<div class="i-solar:stop-circle-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>
<!-- Image Journal Deep Link -->
<button
class="max-h-[10lh] min-h-[1lh]"
-2
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import Header from '@proj-airi/stage-layouts/components/Layouts/Header.vue'
import InteractiveArea from '@proj-airi/stage-layouts/components/Layouts/InteractiveArea.vue'
import MobileHeader from '@proj-airi/stage-layouts/components/Layouts/MobileHeader.vue'
import MobileInteractiveArea from '@proj-airi/stage-layouts/components/Layouts/MobileInteractiveArea.vue'
import workletUrl from '@proj-airi/stage-ui/workers/vad/process.worklet?worker&url'
@@ -210,7 +209,6 @@ const cursorPosition = computed(() => ({
<!-- header -->
<div class="px-0 py-1 md:px-3 md:py-3" w-full gap-2>
<Header class="hidden md:flex" />
<MobileHeader class="flex md:hidden" />
</div>
<!-- page -->
<div relative flex="~ 1 row gap-y-0 gap-x-2 <md:col">
+27
View File
@@ -118,6 +118,29 @@ Line-clamped content container that expands and collapses when the overflowing c
## Misc
### BottomDrawer
Mobile modal surface built on Vaul Vue. It owns the drag handle, overlay,
focus boundary, scroll region, and bottom safe area. Dragging
starts only on the handle, so action buttons and scrolling do not dismiss it.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `title` | `string` | required | Visible and accessible title |
| `minimumHeight` | `'content' \| 'half'` | `'content'` | Uses content height or at least half of the viewport height |
Dismiss with the handle, overlay, or Escape. There is no close button.
**v-model**: `boolean`, defaults to `false`.
**Slots**: `trigger` (one button), `default` (drawer content).
**Emits**: `afterClose()` after the dismissal animation;
`closeAutoFocus(event)` to prevent focus restoration when another modal opens.
Use for mobile action menus and settings panels. Desktop dialogs and panels
that need snap points use their own surface.
### Avatar
Shared user-avatar primitive built on Reka UI. It retries when `src` changes and
@@ -334,6 +357,10 @@ Two-column input for key-value pairs.
### BasicTextarea
Auto-resizing textarea with submit and paste-file events.
The native row count defaults to one, so typing does not introduce a second
row. Content grows when it wraps. Native `rows` attributes can override this minimum.
When set, `defaultHeight` also provides the baseline for content measurement,
so flex layouts do not stretch the empty measurement box.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
+24 -1
View File
@@ -1,3 +1,23 @@
character-switcher:
title: Switch character
manage: Manage characters
empty: No characters yet
failed: Could not switch characters. Try again.
mobile-tools:
sign-in: Sign in to AIRI
account-description: Account and preferences
sound: Sound
character-voice: Character voice
title: Settings
appearance: Appearance
dark-mode: Dark mode
application: Application
more: More
background: Background
settings: All settings
about: About
view: View
hearing: Hearing
chat:
actions:
retry: Retry
@@ -9,7 +29,10 @@ chat:
reasoning: Reasoning
sessions:
title: Conversations
new: + New
new: New conversation
current: Current
confirm-delete: Delete this conversation and its messages?
cancel: Cancel
empty: No conversations yet
cloud-badge: Synced to cloud
new-chat-fallback: New chat
+24 -1
View File
@@ -1,3 +1,23 @@
character-switcher:
title: 切换角色
manage: 管理角色
empty: 暂无角色
failed: 无法切换角色,请重试。
mobile-tools:
sign-in: 登录 AIRI
account-description: 账户与偏好
sound: 声音
character-voice: 角色声音
title: 设置
appearance: 外观
dark-mode: 深色模式
application: 应用
more: 更多
background: 背景
settings: 全部设置
about: 关于
view: 视角
hearing: 听觉
chat:
actions:
retry: 重试
@@ -9,7 +29,10 @@ chat:
reasoning: 思考
sessions:
title: 对话
new: + 新建
new: 新建对话
current: 当前
confirm-delete: 删除此对话及其中的消息?
cancel: 取消
empty: 暂无对话
cloud-badge: 已同步到云
new-chat-fallback: 新建对话
@@ -15,6 +15,11 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
defineProps<{
/** Shows only the account control on the mobile Stage. @default false */
compact?: boolean
}>()
const authStore = useAuthStore()
const { isAuthenticated, user, credits } = storeToRefs(authStore)
const { t } = useI18n()
@@ -31,6 +36,7 @@ const formattedCredits = computed(() => credits.value.toLocaleString())
<!-- NOTICE: The avatar is stored in the localstorage, it will be shown at the first time of the page load, so we do not need the skeleton loading here -->
<template v-if="!isAuthenticated">
<RouterLink
v-if="!compact"
border="2 solid neutral-100/60 dark:neutral-800/30"
bg="neutral-50/70 dark:neutral-800/70"
w-fit flex items-center justify-center rounded-xl p-2 backdrop-blur-md
@@ -41,6 +47,7 @@ const formattedCredits = computed(() => credits.value.toLocaleString())
</RouterLink>
<button
:class="compact ? ['min-h-11 min-w-11 focus-visible:outline-2 focus-visible:outline-primary-500'] : undefined"
border="2 solid neutral-100/60 dark:neutral-800/30"
bg="neutral-50/70 dark:neutral-800/70"
w-fit flex items-center justify-center rounded-xl p-2 backdrop-blur-md
@@ -59,7 +66,8 @@ const formattedCredits = computed(() => credits.value.toLocaleString())
type="button"
:aria-label="userName || t('settings.pages.account.title')"
:class="[
'group flex items-center gap-2 rounded-full border-2 p-1 pl-1 pr-3 outline-none backdrop-blur-md',
'group flex items-center gap-2 rounded-full border-2 p-1 outline-none backdrop-blur-md',
compact ? 'size-11 justify-center focus-visible:ring-2 focus-visible:ring-primary-500' : 'pl-1 pr-3',
'border-neutral-100/60 bg-neutral-50/70 dark:border-neutral-800/30 dark:bg-neutral-800/70',
'hover:bg-neutral-100 data-[state=open]:bg-neutral-100',
'dark:hover:bg-neutral-800 dark:data-[state=open]:bg-neutral-800',
@@ -76,7 +84,7 @@ const formattedCredits = computed(() => credits.value.toLocaleString())
/>
<span
v-if="userName"
v-if="userName && !compact"
:class="[
'max-w-[100px] hidden truncate text-sm font-medium sm:block',
'text-neutral-700 dark:text-neutral-200',
@@ -85,6 +93,7 @@ const formattedCredits = computed(() => credits.value.toLocaleString())
{{ userName }}
</span>
<div
v-if="!compact"
:class="[
'i-solar:alt-arrow-down-linear text-neutral-400',
'transition-transform duration-200',
@@ -2,12 +2,16 @@
import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
import { AboutContent, AboutDialog } from '@proj-airi/stage-ui/components'
import { useBuildInfo } from '@proj-airi/stage-ui/composables'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
defineProps<{
/** Lets a parent menu provide the trigger. @default false */
hideTrigger?: boolean
}>()
const { t } = useI18n()
const show = ref(false)
const show = defineModel<boolean>({ default: false })
const buildInfo = useBuildInfo()
const aboutLinks = [
@@ -25,6 +29,7 @@ const edition = isStageTamagotchi()
<template>
<button
v-if="!hideTrigger"
title="About"
:class="[
'w-fit p-2',
@@ -2,7 +2,7 @@
import { defaultControlConfig as threeCtrlConf, supportedControl as threeSupportedControl, useThreeViewControl } from '@proj-airi/stage-ui-three'
import { defaultControlConfig as l2dCtrlConf, supportedControl as l2dSupportedCtrl, useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
import { Button } from '@proj-airi/ui'
import { Button, GhostButton } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
@@ -29,9 +29,9 @@ function handleViewControlsToggle(targetMode: string) {
</script>
<template>
<div w-full flex flex-1 items-center self-end justify-end gap-2>
<div :class="['w-full flex items-center self-end justify-end gap-2', $slots.default ? 'flex-col' : 'flex-1']">
<Transition name="fade">
<div v-if="controlEnabled?.enabled.value" w-full flex justify-between gap-2>
<div v-if="controlEnabled?.enabled.value" :class="['w-full flex justify-between gap-2', $slots.default && 'px-4 pb-4']">
<Button
v-for="control in controlEnabled.supported"
:key="control"
@@ -45,7 +45,20 @@ function handleViewControlsToggle(targetMode: string) {
</Button>
</div>
</Transition>
<GhostButton
v-if="$slots.default"
block size="unset"
:disabled="!controlEnabled"
:aria-expanded="controlEnabled?.enabled.value ?? false"
:class="['mobile-tool-row order-first min-h-15 rounded-none px-4 py-3']"
@click="controlEnabled && (controlEnabled.enabled.value = !controlEnabled.enabled.value)"
>
<span aria-hidden="true" :class="['i-solar:tuning-outline size-5 shrink-0 text-neutral-400']" />
<span :class="['flex-1 text-left text-sm']"><slot /></span>
<span aria-hidden="true" :class="['size-4 text-neutral-400', controlEnabled?.enabled.value ? 'i-solar:alt-arrow-up-outline' : 'i-solar:alt-arrow-down-outline']" />
</GhostButton>
<button
v-else
w-fit flex items-center self-end justify-center justify-self-end rounded-xl p-2 backdrop-blur-md
border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" title="View"
text="neutral-500 dark:neutral-400"
@@ -60,6 +73,11 @@ function handleViewControlsToggle(targetMode: string) {
</template>
<style scoped>
.mobile-tool-row :deep(.basic-button-content) {
width: 100%;
gap: 0.75rem;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease-in-out;
@@ -1,11 +1,11 @@
<script setup lang="ts">
import HeaderAvatar from './HeaderAvatar.vue'
import MobileHeaderLink from './MobileHeaderLink.vue'
</script>
<template>
<header mb-1 w-full flex items-center justify-between gap-2 px-2>
<MobileHeaderLink />
<HeaderAvatar />
<header
:class="[
'pointer-events-none absolute inset-x-0 top-0 z-30',
'flex items-center justify-between',
'pl-[max(0.75rem,env(safe-area-inset-left))] pr-[max(0.75rem,env(safe-area-inset-right))] pt-[max(0.75rem,env(safe-area-inset-top))]',
]"
>
<slot />
</header>
</template>
@@ -1,35 +0,0 @@
<script setup lang="ts">
import { useTheme } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { RouterLink } from 'vue-router'
import LogoDark from '../../assets/logo-dark.svg'
import Logo from '../../assets/logo.svg'
import { BackgroundKind, useBackgroundStore } from '../../stores/background'
const { isDark: dark } = useTheme()
const { selectedOption } = storeToRefs(useBackgroundStore())
</script>
<template>
<RouterLink
to="/" flex="~" items-center
gap-2 px-2 text-nowrap text-2xl outline-none
>
<template v-if="selectedOption?.kind === BackgroundKind.Wave">
<template v-if="dark">
<img :src="LogoDark" h-8 w-8 class="theme-colored">
</template>
<template v-else>
<img :src="Logo" h-8 w-8 class="theme-colored">
</template>
</template>
</RouterLink>
</template>
<style scoped>
.theme-colored {
filter: hue-rotate(calc(var(--chromatic-hue, 0) * 1deg));
}
</style>
@@ -4,45 +4,39 @@ import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
import { errorMessageFrom } from '@moeru/std'
import { isStageTamagotchi } from '@proj-airi/stage-shared'
import { useThreeViewControl } from '@proj-airi/stage-ui-three'
import { ChatHistory, HearingConfigDialog } from '@proj-airi/stage-ui/components'
import { CharacterSwitcherDrawer, ChatHistory } from '@proj-airi/stage-ui/components'
import { ChatSessionsDrawer } from '@proj-airi/stage-ui/components/scenarios/chat'
import { useAnalytics, useAudioAnalyzer } from '@proj-airi/stage-ui/composables'
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
import { useChatMaintenanceStore } from '@proj-airi/stage-ui/stores/chat/maintenance'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
import { useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { BasicTextarea, useTheme } from '@proj-airi/ui'
import { BasicButton, BasicTextarea } from '@proj-airi/ui'
import { onLongPress, useEventListener, usePointerSwipe } from '@vueuse/core'
import { animate, spring } from 'animejs'
import { storeToRefs } from 'pinia'
import { computed, nextTick, onMounted, onUnmounted, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import ViewControls from '../Layouts/InteractiveArea/Actions/ViewControls.vue'
import IndicatorMicVolume from '../Widgets/IndicatorMicVolume.vue'
import ActionAbout from './InteractiveArea/Actions/About.vue'
import MobileSettingsDrawer from './mobile-settings-drawer.vue'
import MobileHeader from './MobileHeader.vue'
import { useMobileInteractiveAreaLayout } from '../../composables/use-mobile-interactive-area-layout'
import { useTranscriptions } from '../../composables/use-transcriptions'
import { useChatToolCallRerun } from '../../composables/useChatToolCallRerun'
import { useStopSpeakingButton } from '../../composables/useStopSpeakingButton'
import { BackgroundDialogPicker } from '../Backgrounds'
const emit = defineEmits<{
/** Reports the stable height and offset that keep the Stage in the same screen position. */
stageViewportChange: [viewport: { height: number, offsetTop: number }]
}>()
const { isDark, toggleDark } = useTheme()
const chatOrchestrator = useChatStore()
const chatSession = useChatSessionStore()
const chatStream = useChatStreamStore()
const { cleanupMessages } = useChatMaintenanceStore()
const { activeSessionId, messages } = storeToRefs(chatSession)
const { streamingMessage } = storeToRefs(chatStream)
const { activeSendSessionId, activeStreamingMessage, sending } = storeToRefs(chatOrchestrator)
@@ -55,7 +49,7 @@ const isActiveSessionSending = computed(() => (
const visibleStreamingMessage = computed(() => activeSendSessionId.value === activeSessionId.value
? activeStreamingMessage.value
: streamingMessage.value)
const { trackChatMessageDeleted, trackChatMessagesCleared } = useAnalytics()
const { trackChatMessageDeleted } = useAnalytics()
const { rerunToolCall } = useChatToolCallRerun()
async function handleDeleteMessage(index: number) {
@@ -71,21 +65,11 @@ async function handleDeleteMessage(index: number) {
})
}
function handleCleanupMessages() {
const messageCount = messages.value.filter(message => message.role !== 'system').length
cleanupMessages()
trackChatMessagesCleared({
source: 'chat_controls',
message_count: messageCount,
})
}
const messageInput = shallowRef('')
const isComposing = shallowRef(false)
const inputBubbleDocked = shallowRef(false)
const inputBubbleDragging = shallowRef(false)
const inputBubbleAnimating = shallowRef(false)
const backgroundDialogOpen = shallowRef(false)
const sessionsDrawerOpen = shallowRef(false)
const mobileInteractiveArea = useTemplateRef<HTMLElement>('mobileInteractiveArea')
const messageComposer = useTemplateRef<HTMLElement>('messageComposer')
@@ -163,7 +147,7 @@ const messageInputPointerEventsClass = computed(() => {
return 'pointer-events-auto'
})
const { isListening, startStreamingTranscription, stopStreamingTranscription } = useTranscriptions(
useTranscriptions(
{
messageInputRef: messageInput,
sendMessage: handleSend,
@@ -171,7 +155,13 @@ const { isListening, startStreamingTranscription, stopStreamingTranscription } =
},
)
const { showStopSpeakingButton, speechMuted, stopSpeakingFromChat, toggleSpeechMuted } = useStopSpeakingButton()
const toggleTranscription = () => isListening.value ? stopStreamingTranscription() : startStreamingTranscription()
const characterVoiceEnabled = computed({
get: () => !speechMuted.value,
set: (value) => {
if (value === speechMuted.value)
toggleSpeechMuted()
},
})
let suppressNextInputBubbleClick = false
@@ -399,7 +389,26 @@ onUnmounted(() => {
:class="mobileInteractiveAreaClass"
:style="mobileInteractiveAreaStyle"
>
<BackgroundDialogPicker v-model="backgroundDialogOpen" class="pointer-events-auto" />
<MobileHeader>
<BasicButton
size="unset"
data-testid="conversation-selector-button"
:class="[
'pointer-events-auto size-11 shrink-0 rounded-full backdrop-blur-md',
'bg-neutral-50/70 text-neutral-600 dark:bg-neutral-900/70 dark:text-neutral-300',
'focus-visible:outline-2 focus-visible:outline-primary-500',
]"
:title="t('stage.chat.sessions.title')"
:aria-label="t('stage.chat.sessions.title')"
aria-haspopup="dialog"
:aria-expanded="sessionsDrawerOpen"
@click="sessionsDrawerOpen = true"
>
<span aria-hidden="true" :class="['i-solar:dialog-2-outline size-6']" />
</BasicButton>
<CharacterSwitcherDrawer />
<MobileSettingsDrawer v-model:character-voice-enabled="characterVoiceEnabled" />
</MobileHeader>
<div
:class="[
'min-h-0 flex flex-1 flex-col justify-end overflow-hidden',
@@ -451,84 +460,7 @@ onUnmounted(() => {
data-testid="mobile-input-bubble-dock-target"
class="invisible size-10 shrink-0 self-end"
/>
<ActionAbout />
<div flex="~ col" items-end gap-1>
<button
data-testid="conversation-selector-button"
border="2 solid neutral-100/60 dark:neutral-800/30"
bg="neutral-50/70 dark:neutral-800/70"
w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md
:title="t('stage.chat.sessions.title')"
:aria-label="t('stage.chat.sessions.title')"
@click="sessionsDrawerOpen = true"
>
<div i-solar:chat-line-bold-duotone size-5 text="neutral-500 dark:neutral-400" />
</button>
<button
data-testid="speech-mute-button"
:class="[
'w-fit flex items-center self-end justify-center rounded-xl border-2 border-solid p-2 backdrop-blur-md',
'border-neutral-100/60 text-neutral-500 transition-colors active:scale-95 dark:border-neutral-800/30 dark:text-neutral-400',
speechMuted
? 'bg-primary-100/80 text-primary-600 dark:bg-primary-900/60 dark:text-primary-300'
: 'bg-neutral-50/70 hover:text-primary-500 dark:bg-neutral-800/70 dark:hover:text-primary-400',
]"
:title="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
:aria-label="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
:aria-pressed="speechMuted"
@click="toggleSpeechMuted"
>
<div v-if="speechMuted" class="i-solar:volume-cross-bold-duotone size-5" />
<div v-else class="i-solar:volume-loud-bold-duotone size-5" />
</button>
</div>
<ChatSessionsDrawer v-model="sessionsDrawerOpen" />
<HearingConfigDialog
v-model:enabled="enabled"
:transcription="isListening"
:toggle-transcription="toggleTranscription"
:granted="true"
>
<button
border="2 solid neutral-100/60 dark:neutral-800/30"
bg="neutral-50/70 dark:neutral-800/70"
w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md
title="Hearing"
>
<Transition name="fade" mode="out-in">
<IndicatorMicVolume v-if="enabled" size-5 :color-class="isListening ? undefined : 'text-neutral-500 dark:text-neutral-400'" />
<div v-else i-solar:microphone-3-outline size-5 text="neutral-500 dark:neutral-400" />
</Transition>
</button>
</HearingConfigDialog>
<button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Theme" @click="toggleDark()">
<Transition name="fade" mode="out-in">
<div v-if="isDark" i-solar:moon-outline size-5 text="neutral-500 dark:neutral-400" />
<div v-else i-solar:sun-2-outline size-5 text="neutral-500 dark:neutral-400" />
</Transition>
</button>
<button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Background" @click="backgroundDialogOpen = true">
<div i-solar:gallery-wide-bold-duotone size-5 text="neutral-500 dark:neutral-400" />
</button>
<!-- <button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Language">
<div i-solar:earth-outline size-5 text="neutral-500 dark:neutral-400" />
</button> -->
<RouterLink to="/settings" border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Settings">
<div i-solar:settings-outline size-5 text="neutral-500 dark:neutral-400" />
</RouterLink>
<!-- <button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Model">
<div i-solar:face-scan-circle-outline size-5 text="neutral-500 dark:neutral-400" />
</button> -->
<button
border="2 solid neutral-100/60 dark:neutral-800/30"
bg="neutral-50/70 dark:neutral-800/70"
w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md
title="Cleanup Messages"
@click="handleCleanupMessages"
>
<div class="i-solar:trash-bin-2-bold-duotone" />
</button>
<ViewControls />
</div>
</div>
<div
@@ -536,7 +468,7 @@ onUnmounted(() => {
data-testid="mobile-message-composer"
:class="[
'max-h-100dvh max-w-100dvw w-full',
'flex gap-1 px-3 pt-2',
'flex gap-2 px-3 pt-2',
]"
:style="messageComposerStyle"
>
@@ -545,7 +477,7 @@ onUnmounted(() => {
data-testid="mobile-input-bubble"
:data-dragging="inputBubbleDragging"
:class="[
'group relative mx-auto min-h-10 flex origin-center',
'group relative mx-auto min-h-10 flex items-end origin-center',
'touch-none select-none focus-within:touch-auto focus-within:select-text',
inputBubbleDragging || inputBubbleAnimating
? 'transition-none'
@@ -0,0 +1,197 @@
<script setup lang="ts">
import { HearingConfig } from '@proj-airi/stage-ui/components'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { Avatar, BasicButton, BottomDrawer, Checkbox, GhostButton, useTheme } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink, useRouter } from 'vue-router'
import ActionAbout from './InteractiveArea/Actions/About.vue'
import ViewControls from './InteractiveArea/Actions/ViewControls.vue'
import { BackgroundDialogPicker } from '../Backgrounds'
const characterVoiceEnabled = defineModel<boolean>('characterVoiceEnabled', { required: true })
const { t } = useI18n()
const { isDark } = useTheme()
const authStore = useAuthStore()
const { isAuthenticated, user } = storeToRefs(authStore)
const router = useRouter()
const settingsAudioDevice = useSettingsAudioDevice()
const hearingOpen = shallowRef(false)
const backgroundDialogOpen = shallowRef(false)
const settingsOpen = shallowRef(false)
const aboutOpen = shallowRef(false)
// Finish closing settings before opening a sibling modal, so focus and scroll locks have one owner.
const nextPanel = shallowRef<'background' | 'about' | 'account' | 'hearing'>()
function openPanel(panel: 'background' | 'about' | 'account' | 'hearing') {
nextPanel.value = panel
settingsOpen.value = false
}
function finishSettingsClose() {
if (nextPanel.value === 'background') {
backgroundDialogOpen.value = true
}
else if (nextPanel.value === 'about') {
aboutOpen.value = true
}
else if (nextPanel.value === 'hearing') {
hearingOpen.value = true
}
else if (nextPanel.value === 'account') {
if (isAuthenticated.value)
void router.push('/settings/account')
else
authStore.needsLogin = true
}
nextPanel.value = undefined
}
watch(hearingOpen, async (open) => {
if (open)
await settingsAudioDevice.askPermission()
})
</script>
<template>
<BottomDrawer
v-model="settingsOpen"
:title="t('stage.mobile-tools.title')"
@after-close="finishSettingsClose"
@close-auto-focus="event => { if (nextPanel) event.preventDefault() }"
>
<template #trigger>
<BasicButton
size="unset"
:aria-label="t('stage.mobile-tools.title')"
:title="t('stage.mobile-tools.title')"
data-testid="mobile-settings-button"
:class="['pointer-events-auto size-11 rounded-full bg-neutral-50/70 text-neutral-600 backdrop-blur-md dark:bg-neutral-900/70 dark:text-neutral-300', 'focus-visible:outline-2 focus-visible:outline-primary-500']"
>
<span aria-hidden="true" :class="['i-solar:settings-outline size-6']" />
</BasicButton>
</template>
<GhostButton
block size="unset"
:class="[
'mobile-tool-row rounded-2xl',
'[&_.basic-button-content]:w-full [&_.basic-button-content]:gap-3 [&_[aria-hidden]]:shrink-0',
isAuthenticated ? 'mobile-tool-row-authenticated mb-4 min-h-16' : 'mobile-tool-row-anonymous mb-3 min-h-14',
]"
@click="openPanel('account')"
>
<Avatar v-if="isAuthenticated" :src="user?.image" :class="['size-12 shrink-0 rounded-full bg-neutral-200 text-neutral-500 dark:bg-neutral-700']" />
<span :class="['min-w-0 flex-1 text-left']">
<span :class="['block truncate text-base font-semibold']">{{ isAuthenticated ? user?.name : t('stage.mobile-tools.sign-in') }}</span>
<span :class="['block text-xs text-neutral-500 dark:text-neutral-400']">{{ t('stage.mobile-tools.account-description') }}</span>
</span>
<span aria-hidden="true" :class="['i-solar:alt-arrow-right-outline size-4 shrink-0 text-neutral-400']" />
</GhostButton>
<section :class="['mb-4']">
<h3 :class="['mb-2 px-1 text-xs font-semibold text-neutral-500 dark:text-neutral-400']">
{{ t('stage.mobile-tools.appearance') }}
</h3>
<div :class="['overflow-hidden rounded-2xl bg-white dark:bg-neutral-800/60']">
<label :class="['min-h-13 flex cursor-pointer items-center gap-3 px-4 py-3']">
<span aria-hidden="true" :class="['i-solar:moon-outline size-5 shrink-0 text-neutral-400']" />
<span :class="['flex-1 text-sm']">{{ t('stage.mobile-tools.dark-mode') }}</span>
<Checkbox v-model="isDark" :aria-label="t('stage.mobile-tools.dark-mode')" />
</label>
<div :class="['mx-4 border-t border-neutral-100 dark:border-neutral-700/50']" />
<GhostButton
block size="unset"
:class="['mobile-tool-row min-h-13 rounded-none px-4 py-3']"
@click="openPanel('background')"
>
<span aria-hidden="true" :class="['i-solar:gallery-wide-outline size-5 shrink-0 text-neutral-400']" />
<span :class="['flex-1 text-left text-sm']">{{ t('stage.mobile-tools.background') }}</span>
<span aria-hidden="true" :class="['i-solar:alt-arrow-right-outline size-4 text-neutral-400']" />
</GhostButton>
<div :class="['mx-4 border-t border-neutral-100 dark:border-neutral-700/50']" />
<ViewControls>
{{ t('stage.mobile-tools.view') }}
</ViewControls>
</div>
</section>
<section :class="['mb-4']">
<h3 :class="['mb-2 px-1 text-xs font-semibold text-neutral-500 dark:text-neutral-400']">
{{ t('stage.mobile-tools.sound') }}
</h3>
<div :class="['overflow-hidden rounded-2xl bg-white dark:bg-neutral-800/60']">
<label :class="['min-h-13 flex cursor-pointer items-center gap-3 px-4 py-3']">
<span aria-hidden="true" :class="['i-solar:volume-loud-outline size-5 shrink-0 text-neutral-400']" />
<span :class="['flex-1 text-sm']">{{ t('stage.mobile-tools.character-voice') }}</span>
<Checkbox v-model="characterVoiceEnabled" :aria-label="t('stage.mobile-tools.character-voice')" />
</label>
<div :class="['mx-4 border-t border-neutral-100 dark:border-neutral-700/50']" />
<GhostButton block size="unset" :class="['mobile-tool-row min-h-13 rounded-none px-4 py-3']" @click="openPanel('hearing')">
<span aria-hidden="true" :class="['i-solar:microphone-3-outline size-5 shrink-0 text-neutral-400']" />
<span :class="['flex-1 text-left text-sm']">{{ t('stage.mobile-tools.hearing') }}</span>
<span aria-hidden="true" :class="['i-solar:alt-arrow-right-outline size-4 text-neutral-400']" />
</GhostButton>
</div>
</section>
<section :class="['mb-4']">
<h3 :class="['mb-2 px-1 text-xs font-semibold text-neutral-500 dark:text-neutral-400']">
{{ t('stage.mobile-tools.application') }}
</h3>
<div :class="['overflow-hidden rounded-2xl bg-white dark:bg-neutral-800/60']">
<RouterLink
to="/settings"
:class="[
'min-h-13 flex items-center gap-3 px-4 py-3 text-sm',
'hover:bg-primary-500/10 focus-visible:outline-2 focus-visible:outline-primary-500',
]"
@click="settingsOpen = false"
>
<span aria-hidden="true" :class="['i-solar:settings-outline size-5 shrink-0 text-neutral-400']" />
<span :class="['flex-1']">{{ t('stage.mobile-tools.settings') }}</span>
<span aria-hidden="true" :class="['i-solar:alt-arrow-right-outline size-4 text-neutral-400']" />
</RouterLink>
<div :class="['mx-4 border-t border-neutral-100 dark:border-neutral-700/50']" />
<GhostButton
block size="unset"
:class="['mobile-tool-row min-h-13 rounded-none px-4 py-3']"
@click="openPanel('about')"
>
<span aria-hidden="true" :class="['i-solar:info-circle-outline size-5 text-neutral-400']" />
<span :class="['flex-1 text-left text-sm']">{{ t('stage.mobile-tools.about') }}</span>
<span aria-hidden="true" :class="['i-solar:alt-arrow-right-outline size-4 text-neutral-400']" />
</GhostButton>
</div>
</section>
</BottomDrawer>
<BottomDrawer
v-model="hearingOpen"
:title="t('stage.mobile-tools.hearing')"
@close-auto-focus="event => event.preventDefault()"
@after-close="settingsOpen = true"
>
<HearingConfig :granted="true" />
</BottomDrawer>
<BackgroundDialogPicker v-model="backgroundDialogOpen" class="pointer-events-auto" />
<ActionAbout v-model="aboutOpen" hide-trigger />
</template>
<style scoped>
.mobile-tool-row :deep(.basic-button-content) {
width: 100%;
gap: 0.75rem;
}
.mobile-tool-row :deep([aria-hidden]) {
flex-shrink: 0;
}
.mobile-tool-row.mobile-tool-row-authenticated {
padding: 0.75rem 1rem !important;
}
.mobile-tool-row.mobile-tool-row-anonymous {
padding: 0.5rem 0 !important;
}
</style>
@@ -1,10 +1,6 @@
<script setup lang="ts">
import { ChatSessionsDrawer } from '@proj-airi/stage-ui/components/scenarios/chat'
import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
import { useChatMaintenanceStore } from '@proj-airi/stage-ui/stores/chat/maintenance'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
import { useTheme } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -13,24 +9,12 @@ import ViewControls from '../Layouts/InteractiveArea/Actions/ViewControls.vue'
import { useStopSpeakingButton } from '../../composables/useStopSpeakingButton'
import { BackgroundDialogPicker } from '../Backgrounds'
const { cleanupMessages } = useChatMaintenanceStore()
const { messages } = storeToRefs(useChatSessionStore())
const { trackChatMessagesCleared } = useAnalytics()
const { isDark, toggleDark } = useTheme()
const { speechMuted, toggleSpeechMuted } = useStopSpeakingButton()
const { t } = useI18n()
const backgroundDialogOpen = ref(false)
const sessionsDrawerOpen = ref(false)
function handleCleanupMessages() {
const messageCount = messages.value.filter(message => message.role !== 'system').length
cleanupMessages()
trackChatMessagesCleared({
source: 'chat_controls',
message_count: messageCount,
})
}
</script>
<template>
@@ -70,18 +54,6 @@ function handleCleanupMessages() {
</button>
</div>
<ViewControls />
<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>
<button
class="max-h-[10lh] min-h-[1lh]"
bg="neutral-100 dark:neutral-800"
@@ -0,0 +1,94 @@
import type { AiriCard } from '../../types/airiCard'
import { PiniaColada } from '@pinia/colada'
import { createPinia } from 'pinia'
import { expect, it } from 'vitest'
import { render } from 'vitest-browser-vue'
import { page, userEvent } from 'vitest/browser'
import { defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import { createMemoryHistory, createRouter } from 'vue-router'
import CharacterSwitcherDrawer from './character-switcher-drawer.vue'
import { useAiriCardStore } from '../../stores/modules/airi-card'
import '@unocss/reset/tailwind.css'
import 'virtual:uno.css'
function card(name: string): AiriCard {
return {
name,
version: '1.0.0',
extensions: {
airi: {
agents: {},
modules: {
consciousness: { provider: '', model: '' },
speech: { provider: '', model: '', voice_id: '' },
vision: { provider: '', model: '' },
},
},
},
}
}
async function mountSwitcher(name = 'ReLU') {
const pinia = createPinia()
pinia.state.value['airi-card'] = {
cards: new Map([['default', card(name)], ['second', card('Hiyori')]]),
activeCardId: 'default',
}
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div />' } }, { path: '/settings/airi-card', component: { template: '<div />' } }],
})
await router.push('/')
const screen = await render(defineComponent({
components: { CharacterSwitcherDrawer },
setup() {
void useAiriCardStore().initialize()
},
template: '<header style="display:flex;width:100%"><span style="width:44px;flex-shrink:0" /><CharacterSwitcherDrawer /><span style="width:44px;flex-shrink:0" /></header>',
}), {
global: {
plugins: [pinia, PiniaColada, router, createI18n({
legacy: false,
locale: 'en',
missingWarn: false,
fallbackWarn: false,
messages: { en: { stage: { 'character-switcher': { title: 'Switch character', manage: 'Manage characters', empty: 'No characters yet', failed: 'Could not switch characters' } } } },
})],
},
})
return { screen, router, store: useAiriCardStore(pinia) }
}
it('selects a character through the real store and opens character management', async () => {
await page.viewport(390, 844)
const { screen, store, router } = await mountSwitcher()
await screen.getByTestId('character-selector-button').click()
await expect.poll(() => page.getByRole('dialog').element().getBoundingClientRect().height).toBeGreaterThanOrEqual(422)
await expect.element(page.getByRole('button', { name: 'ReLU', exact: true })).toHaveAttribute('aria-pressed', 'true')
expect(document.querySelector('[data-vaul-handle]')).not.toBeNull()
await page.getByRole('button', { name: 'Hiyori', exact: true }).click()
await expect.poll(() => store.activeCardId).toBe('second')
await expect.element(screen.getByTestId('character-selector-button')).toHaveTextContent('Hiyori')
await expect.element(page.getByRole('dialog')).not.toBeInTheDocument()
await screen.getByTestId('character-selector-button').click()
await page.getByRole('button', { name: 'Manage characters' }).click()
await expect.poll(() => router.currentRoute.value.path).toBe('/settings/airi-card')
})
it('truncates long titles and restores trigger focus on dismissal', async () => {
await page.viewport(320, 640)
const { screen, store } = await mountSwitcher('A very long character name that must not move the settings button')
const trigger = screen.getByTestId('character-selector-button')
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(320)
expect(trigger.element().getBoundingClientRect().height).toBeGreaterThanOrEqual(44)
await trigger.click()
await userEvent.keyboard('{Escape}')
await expect.element(page.getByRole('dialog')).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
expect(store.activeCardId).toBe('default')
})
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { Avatar, BasicButton, BottomDrawer, GhostButton } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useDisplayModelsStore } from '../../stores/display-models'
import { useAiriCardStore } from '../../stores/modules/airi-card'
const { t } = useI18n()
const router = useRouter()
const cardStore = useAiriCardStore()
const { cards, activeCard, activeCardId } = storeToRefs(cardStore)
const { displayModels } = storeToRefs(useDisplayModelsStore())
const open = shallowRef(false)
const switching = shallowRef(false)
const failed = shallowRef(false)
const manageAfterClose = shallowRef(false)
const entries = computed(() => Array.from(cards.value, ([id, card]) => ({
id,
name: card.name,
preview: displayModels.value.find(model => model.id === card.extensions.airi.modules.displayModelId)?.previewImage,
})))
async function selectCharacter(id: string) {
if (switching.value)
return
if (id === activeCardId.value) {
open.value = false
return
}
switching.value = true
failed.value = false
try {
if (await cardStore.activateCard(id))
open.value = false
else
failed.value = true
}
catch {
failed.value = true
}
finally {
switching.value = false
}
}
function manageCharacters() {
// Let the drawer release its focus and scroll locks before changing routes.
manageAfterClose.value = true
open.value = false
}
function finishClose() {
failed.value = false
if (manageAfterClose.value) {
manageAfterClose.value = false
void router.push('/settings/airi-card')
}
}
</script>
<template>
<div :class="['min-w-0 flex flex-1 justify-center px-2']">
<BottomDrawer
v-model="open"
:title="t('stage.character-switcher.title')"
minimum-height="half"
@after-close="finishClose"
@close-auto-focus="event => { if (manageAfterClose) event.preventDefault() }"
>
<template #trigger>
<BasicButton
size="unset"
data-testid="character-selector-button"
:aria-label="`${t('stage.character-switcher.title')}: ${activeCard?.name ?? t('stage.character-switcher.empty')}`"
:title="activeCard?.name"
:class="[
'pointer-events-auto h-11 min-w-0 max-w-full rounded-full px-3',
'text-neutral-700 dark:text-neutral-200',
'focus-visible:outline-2 focus-visible:outline-primary-500',
'[&_.basic-button-content]:min-w-0',
]"
>
<span :class="['truncate text-base font-semibold']">{{ activeCard?.name ?? t('stage.character-switcher.empty') }}</span>
<span aria-hidden="true" :class="['i-solar:alt-arrow-down-outline size-4 shrink-0']" />
</BasicButton>
</template>
<div :class="['grid grid-cols-2 gap-3']">
<BasicButton
v-for="entry in entries"
:key="entry.id"
size="unset"
:aria-pressed="entry.id === activeCardId"
:aria-label="entry.name"
:disabled="switching"
:class="[
'relative min-w-0 rounded-2xl border border-solid p-2',
'[&_.basic-button-content]:w-full [&_.basic-button-content]:min-w-0 [&_.basic-button-content]:flex-col',
'focus-visible:outline-2 focus-visible:outline-primary-500',
entry.id === activeCardId ? 'border-primary-500 bg-primary-50 dark:bg-primary-950' : 'border-neutral-200 dark:border-neutral-700',
]"
@click="selectCharacter(entry.id)"
>
<Avatar :src="entry.preview" :class="['aspect-[4/3] w-full rounded-xl bg-neutral-100 text-neutral-400 dark:bg-neutral-800']">
<template #fallback>
<span aria-hidden="true" :class="['i-solar:user-rounded-outline size-10']" />
</template>
</Avatar>
<span :class="['w-full truncate text-sm']" :title="entry.name">{{ entry.name }}</span>
<span v-if="entry.id === activeCardId" aria-hidden="true" :class="['i-solar:check-circle-bold absolute right-3 top-3 size-6 text-primary-500']" />
</BasicButton>
</div>
<p v-if="!entries.length" :class="['py-8 text-center text-neutral-500']">
{{ t('stage.character-switcher.empty') }}
</p>
<p v-if="failed" role="alert" :class="['mt-3 text-sm text-red-600 dark:text-red-400']">
{{ t('stage.character-switcher.failed') }}
</p>
<GhostButton
block size="unset" :disabled="switching"
:class="['mt-4 min-h-11 rounded-xl px-3 [&_.basic-button-content]:w-full']"
@click="manageCharacters"
>
<span aria-hidden="true" :class="['i-solar:users-group-rounded-outline size-5 shrink-0']" />
<span :class="['flex-1 text-left']">{{ t('stage.character-switcher.manage') }}</span>
<span aria-hidden="true" :class="['i-solar:alt-arrow-right-outline size-4']" />
</GhostButton>
</BottomDrawer>
</div>
</template>
@@ -1,4 +1,5 @@
export { default as Alert } from './alert.vue'
export { default as CharacterSwitcherDrawer } from './character-switcher-drawer.vue'
export { default as ErrorContainer } from './error-container.vue'
export { default as ProfileSwitcherPopover } from './profile-switcher-popover.vue'
export type { Props as ProfileSwitcherPopoverProps } from './profile-switcher-popover.vue'
@@ -0,0 +1,37 @@
import { BasicTextarea } from '@proj-airi/ui'
import { expect, it } from 'vitest'
import { render } from 'vitest-browser-vue'
import { page } from 'vitest/browser'
import { defineComponent } from 'vue'
it('keeps a mobile composer on one line until its content wraps', async () => {
// ROOT CAUSE:
// Resetting height to auto measured the native two-row minimum, so typing
// one character changed the mobile textarea from 32px to 56px.
// Even rows=1 still measured the flex parent's 40px minimum. Measure from
// the configured single-line height instead of the stretched auto height.
render(defineComponent({
components: { BasicTextarea },
template: `<div style="display:flex;min-height:40px">
<BasicTextarea aria-label="Message" default-height="1lh"
style="box-sizing:border-box;width:300px;font-size:16px;line-height:24px;padding:2px 16px;border:2px solid;min-height:32px" />
</div>`,
}))
const input = page.getByRole('textbox', { name: 'Message' })
const element = input.element()
await expect.poll(() => element.getBoundingClientRect().height).toBe(32)
await input.fill('a')
await expect.poll(() => element.style.height).toBe('32px')
expect(element.getBoundingClientRect().height).toBe(32)
await input.fill('你好')
await expect.poll(() => element.style.height).toBe('32px')
expect(element.getBoundingClientRect().height).toBe(32)
await input.fill('First line\nSecond line')
await expect.poll(() => element.getBoundingClientRect().height).toBe(56)
await input.fill('A long message '.repeat(12))
await expect.poll(() => element.getBoundingClientRect().height).toBeGreaterThan(56)
await input.fill('short')
await expect.poll(() => element.getBoundingClientRect().height).toBe(32)
await input.fill('')
await expect.poll(() => element.getBoundingClientRect().height).toBe(32)
})
@@ -2,11 +2,15 @@ import type { ChatSessionMeta } from '../../../../types/chat-session'
import { describe, expect, it } from 'vitest'
import { render } from 'vitest-browser-vue'
import { page } from 'vitest/browser'
import { defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import SessionsDialog from './sessions-dialog.vue'
import '@unocss/reset/tailwind.css'
import 'virtual:uno.css'
function createTestI18n() {
return createI18n({
legacy: false,
@@ -20,6 +24,9 @@ function createTestI18n() {
'new': 'New chat',
'empty': 'No chats',
'delete': 'Delete',
'current': 'Current',
'cancel': 'Cancel',
'confirm-delete': 'Delete this conversation and its messages?',
'cloud-badge': 'Cloud synced',
},
},
@@ -42,7 +49,7 @@ function sessionMeta(sessionId: string, updatedAt: number): ChatSessionMeta {
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' },
]) {
], isDesktop = false) {
return defineComponent({
name: 'SessionsDialogHarness',
components: { SessionsDialog },
@@ -56,15 +63,15 @@ function createHarness(rows = [
deleted,
selected,
rows,
isDesktop,
}
},
template: `
<SessionsDialog
:open="true"
:rows="rows"
:is-desktop="false"
:is-desktop="isDesktop"
:is-creating-session="false"
mobile-padding-bottom="24px"
@new-session="created += 1"
@select-session="selected = $event"
@delete-session="deleted = $event"
@@ -77,7 +84,37 @@ function createHarness(rows = [
}
describe('sessions dialog actions', () => {
it('keeps the current marker and deletion confirmation usable at 320 pixels', async () => {
await page.viewport(320, 740)
const screen = await render(createHarness(), { global: { plugins: [createTestI18n()] } })
await expect.poll(() => screen.getByRole('dialog').element().getBoundingClientRect().height).toBeGreaterThanOrEqual(370)
const current = screen.getByRole('button', { name: /^First chat/ })
await expect.element(current).toHaveAttribute('aria-current', 'true')
const remove = screen.getByRole('button', { name: 'Delete: Second chat' })
expect(remove.element().getBoundingClientRect().width).toBeGreaterThanOrEqual(44)
expect(remove.element().getBoundingClientRect().height).toBeGreaterThanOrEqual(44)
await remove.click()
await expect.element(screen.getByRole('status')).toHaveTextContent('Delete this conversation and its messages?')
await expect.element(screen.getByLabelText('deleted-session-id')).toHaveTextContent('none')
await screen.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect.element(remove).toHaveFocus()
await expect.element(screen.getByRole('status')).not.toBeInTheDocument()
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(320)
expect(document.querySelector('[data-vaul-handle]')).not.toBeNull()
})
it('keeps the desktop surface centered and its list actions accessible', async () => {
await page.viewport(1280, 900)
const screen = await render(createHarness(undefined, true), { global: { plugins: [createTestI18n()] } })
const dialog = screen.getByRole('dialog').element()
await expect.poll(() => Math.round(dialog.getBoundingClientRect().x + dialog.getBoundingClientRect().width / 2)).toBe(640)
expect(document.querySelector('[data-vaul-handle]')).toBeNull()
await screen.getByRole('button', { name: /^Second chat/ }).click()
await expect.element(screen.getByLabelText('selected-session-id')).toHaveTextContent('session-two')
})
it('constrains long mobile session lists to a scrollable viewport', async () => {
await page.viewport(390, 844)
const rows = Array.from({ length: 30 }, (_, index) => ({
meta: sessionMeta(`session-${index}`, 30 - index),
preview: `Chat ${index}`,
@@ -107,8 +144,8 @@ describe('sessions dialog actions', () => {
//
// Vaul handled every pointer release on DrawerContent, including releases
// from its action buttons, and unmounted the sheet before `click` ran.
// The replacement uses a Reka dialog surface whose buttons emit one action
// each without a competing gesture-release lifecycle.
// The shared drawer restricts dragging to its handle. List actions must
// still emit once without a competing gesture-release lifecycle.
const screen = await render(createHarness(), {
global: {
plugins: [createTestI18n()],
@@ -121,10 +158,12 @@ describe('sessions dialog actions', () => {
await expect.element(screen.getByLabelText('created-session-count')).toHaveTextContent('1')
await screen.getByRole('button', { name: 'Delete: Second chat' }).click()
await expect.element(screen.getByLabelText('deleted-session-id')).toHaveTextContent('none')
await screen.getByRole('button', { name: 'Delete', exact: true }).click()
await expect.element(screen.getByLabelText('deleted-session-id')).toHaveTextContent('session-two')
await expect.element(screen.getByLabelText('selected-session-id')).toHaveTextContent('none')
await screen.getByRole('button', { name: 'First chat now' }).click()
await screen.getByRole('button', { name: /^First chat/ }).click()
await expect.element(screen.getByLabelText('selected-session-id')).toHaveTextContent('session-one')
})
})
@@ -1,26 +1,18 @@
<script setup lang="ts">
import type { ChatSessionMeta } from '../../../../types/chat-session'
import type { SessionRow } from './sessions-list.vue'
import { ScrollableArea } from '@proj-airi/ui'
import { BottomDrawer } from '@proj-airi/ui'
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { useI18n } from 'vue-i18n'
interface SessionRow {
meta: ChatSessionMeta
preview: string
isActive: boolean
updatedAtLabel: string
}
import SessionsList from './sessions-list.vue'
interface Props {
defineProps<{
open: boolean
rows: SessionRow[]
isDesktop: boolean
isCreatingSession: boolean
mobilePaddingBottom: string
}
defineProps<Props>()
}>()
const emit = defineEmits<{
'deleteSession': [sessionId: string]
@@ -33,108 +25,48 @@ const { t } = useI18n()
</script>
<template>
<DialogRoot :open="open" @update:open="value => emit('update:open', value)">
<BottomDrawer
v-if="!isDesktop"
:model-value="open"
:title="t('stage.chat.sessions.title')"
minimum-height="half"
@update:model-value="emit('update:open', $event)"
>
<template v-if="$slots.trigger" #trigger>
<slot name="trigger" />
</template>
<SessionsList
:rows="rows"
:is-creating-session="isCreatingSession"
@new-session="emit('newSession')"
@select-session="emit('selectSession', $event)"
@delete-session="emit('deleteSession', $event)"
/>
</BottomDrawer>
<DialogRoot v-else :open="open" @update:open="emit('update:open', $event)">
<slot name="trigger" />
<DialogPortal>
<DialogOverlay
:class="[
'fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm',
'data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn',
]"
:class="['fixed inset-0 z-[9999] bg-black/35', 'data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn']"
/>
<DialogContent
:aria-describedby="undefined"
:class="[
'fixed z-[9999] flex flex-col overflow-hidden bg-white/95 shadow-xl outline-none backdrop-blur-md dark:bg-neutral-900/95',
isDesktop
? 'left-1/2 top-1/2 max-h-[80dvh] max-w-md w-[92dvw] rounded-2xl -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow'
: 'bottom-0 left-0 right-0 max-h-[85dvh] rounded-t-[32px]',
'pointer-events-auto fixed left-1/2 top-1/2 z-[9999] max-h-[80dvh] max-w-md w-[92dvw] flex flex-col rounded-3xl p-5',
'-translate-x-1/2 -translate-y-1/2 bg-neutral-50 text-neutral-900 shadow-xl outline-none dark:bg-neutral-900 dark:text-neutral-100',
'data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow',
]"
:style="isDesktop ? undefined : { paddingBottom: mobilePaddingBottom }"
>
<div
v-if="!isDesktop"
:class="['mx-auto mt-3 h-1.5 w-12 shrink-0 rounded-full bg-neutral-400 dark:bg-neutral-600']"
aria-hidden="true"
<DialogTitle :class="['mb-5 text-xl font-semibold tracking-tight']">
{{ t('stage.chat.sessions.title') }}
</DialogTitle>
<SessionsList
:rows="rows"
:is-creating-session="isCreatingSession"
@new-session="emit('newSession')"
@select-session="emit('selectSession', $event)"
@delete-session="emit('deleteSession', $event)"
/>
<div :class="['flex min-h-0 flex-1 flex-col']">
<div :class="['flex items-center justify-between px-5 pt-5 pb-3']">
<DialogTitle :class="['text-base font-medium text-neutral-700 dark:text-neutral-200']">
{{ t('stage.chat.sessions.title') }}
</DialogTitle>
<button
type="button"
:class="[
'rounded-lg px-3 py-1.5 text-xs font-medium',
'bg-primary-100/60 text-primary-700 dark:bg-primary-900/40 dark:text-primary-200',
'hover:bg-primary-200/70 dark:hover:bg-primary-800/50',
'transition-colors',
]"
:disabled="isCreatingSession"
@click="emit('newSession')"
>
{{ t('stage.chat.sessions.new') }}
</button>
</div>
<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>
<div
v-for="row in rows"
:key="row.meta.sessionId"
:class="[
'group relative mb-1 w-full rounded-xl transition-colors',
row.isActive
? 'bg-primary-100/70 dark:bg-primary-900/40'
: 'hover:bg-neutral-100/80 dark:hover:bg-neutral-800/60',
]"
>
<button
type="button"
:class="['w-full flex flex-col gap-1 px-3 py-3 text-left outline-none']"
@click="emit('selectSession', row.meta.sessionId)"
>
<div :class="['flex items-center gap-2 text-sm font-medium text-neutral-700 dark:text-neutral-200']">
<span :class="['flex-1 truncate']">{{ row.preview }}</span>
<span
v-if="row.meta.cloudChatId"
:class="['shrink-0 rounded px-1.5 py-0.5 text-[10px] uppercase tracking-wide', 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300']"
:title="t('stage.chat.sessions.cloud-badge')"
>
cloud
</span>
<span :class="['w-7']" />
</div>
<div :class="['text-[11px] text-neutral-500 dark:text-neutral-400']">
{{ row.updatedAtLabel }}
</div>
</button>
<button
type="button"
:class="[
'absolute right-2 top-2 z-10 h-7 w-7 flex items-center justify-center rounded-md',
'opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100',
'text-neutral-400 hover:bg-red-500/10 hover:text-red-500',
'transition-opacity duration-150',
]"
:aria-label="`${t('stage.chat.sessions.delete')}: ${row.preview}`"
:title="t('stage.chat.sessions.delete')"
@click.stop="emit('deleteSession', row.meta.sessionId)"
>
<div class="i-solar:trash-bin-trash-bold-duotone h-4 w-4" />
</button>
</div>
</ScrollableArea>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
@@ -11,6 +11,9 @@ import SessionsDrawer from './sessions-drawer.vue'
import { useChatStore } from '../../../../stores/chat'
import { useChatSessionStore } from '../../../../stores/chat/session-store'
import '@unocss/reset/tailwind.css'
import 'virtual:uno.css'
function createTestI18n() {
return createI18n({
legacy: false,
@@ -26,6 +29,9 @@ function createTestI18n() {
'new': 'New chat',
'empty': 'No chats',
'delete': 'Delete',
'current': 'Current',
'cancel': 'Cancel',
'confirm-delete': 'Delete this conversation and its messages?',
'cloud-badge': 'Cloud synced',
},
},
@@ -97,9 +103,10 @@ describe('sessions drawer orchestration', () => {
await screen.rerender({ modelValue: true })
await screen.getByRole('button', { name: 'Delete: Chat B' }).click()
await screen.getByRole('button', { name: 'Delete', exact: true }).click()
await vi.waitFor(() => expect(chat.deleteSession).toHaveBeenCalledWith('session-b'))
await screen.getByRole('button', { name: /^Chat C / }).click()
await screen.getByRole('button', { name: /^Chat C/ }).click()
expect(chatSession.activeSessionId).toBe('session-c')
resolveDelete?.()
@@ -137,7 +144,7 @@ describe('sessions drawer orchestration', () => {
await screen.getByRole('button', { name: 'New chat' }).click()
await vi.waitFor(() => expect(chatSession.createSession).toHaveBeenCalledWith('default', { setActive: false }))
await screen.getByRole('button', { name: /^Chat C / }).click()
await screen.getByRole('button', { name: /^Chat C/ }).click()
expect(chatSession.activeSessionId).toBe('session-c')
resolveCreate?.('session-new')
@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { ChatSessionMeta } from '../../../../types/chat-session'
import type { SessionRow } from './sessions-list.vue'
import { useResizeObserver, useScreenSafeArea } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import SessionsDialog from './sessions-dialog.vue'
@@ -20,8 +20,7 @@ import { useConsciousnessStore } from '../../../../stores/modules/consciousness'
const showDialog = defineModel({ type: Boolean, default: false, required: false })
const { isDesktop } = useBreakpoints()
const screenSafeArea = useScreenSafeArea()
const { t } = useI18n()
const { t, locale } = useI18n()
const chatSession = useChatSessionStore()
const chat = useChatStore()
@@ -35,16 +34,6 @@ const { trackChatSessionSelected, trackChatSessionStarted } = useAnalytics()
// second click from creating an orphan session while the first is pending.
const isCreatingSession = ref(false)
useResizeObserver(document.documentElement, () => screenSafeArea.update())
onMounted(() => screenSafeArea.update())
interface SessionRow {
meta: ChatSessionMeta
preview: string
isActive: boolean
updatedAtLabel: string
}
// Keep another account's sessions hidden while an account swap rehydrates.
const ownedSessions = computed(() => {
const effectiveUserId = userId.value || 'local'
@@ -91,7 +80,7 @@ const RELATIVE_UNITS: Array<[Intl.RelativeTimeFormatUnit, number]> = [
* // => '5 minutes ago'
*/
function formatUpdatedAt(ts: number): string {
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
const formatter = new Intl.RelativeTimeFormat(locale.value, { numeric: 'auto' })
const delta = ts - Date.now()
const abs = Math.abs(delta)
for (const [unit, ms] of RELATIVE_UNITS) {
@@ -115,11 +104,6 @@ const rows = computed<SessionRow[]>(() => {
return list
})
const mobilePaddingBottom = computed(() => {
const safeAreaBottom = Number.parseFloat(screenSafeArea.bottom.value.replace('px', ''))
return `${Math.max(safeAreaBottom, 24)}px`
})
async function selectSession(sessionId: string) {
const selectedRow = rows.value.find(row => row.meta.sessionId === sessionId)
if (sessionId !== activeSessionId.value && selectedRow) {
@@ -188,7 +172,6 @@ watch(showDialog, async (open) => {
:rows="rows"
:is-desktop="isDesktop"
:is-creating-session="isCreatingSession"
:mobile-padding-bottom="mobilePaddingBottom"
@new-session="startNewSession"
@select-session="selectSession"
@delete-session="chat.deleteSession"
@@ -0,0 +1,132 @@
<script lang="ts">
import type { ChatSessionMeta } from '../../../../types/chat-session'
import { BasicButton, Button, GhostButton, ScrollableArea } from '@proj-airi/ui'
import { shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
/** A conversation preview prepared by the session owner for either dialog surface. */
export interface SessionRow {
meta: ChatSessionMeta
preview: string
isActive: boolean
updatedAtLabel: string
}
</script>
<script setup lang="ts">
defineProps<{
rows: SessionRow[]
isCreatingSession: boolean
}>()
const emit = defineEmits<{
deleteSession: [sessionId: string]
newSession: []
selectSession: [sessionId: string]
}>()
const { t } = useI18n()
// The confirmation belongs to this open list, never to persisted session state.
const pendingDeletion = shallowRef<string>()
let deleteTrigger: HTMLElement | undefined
function requestDeletion(sessionId: string, event: MouseEvent) {
deleteTrigger = event.currentTarget as HTMLElement
pendingDeletion.value = sessionId
}
function cancelDeletion() {
pendingDeletion.value = undefined
deleteTrigger?.focus()
}
</script>
<template>
<div :class="['min-h-0 flex flex-col gap-4']">
<Button
block color="primary" variant="secondary" size="unset"
:class="['min-h-12 shrink-0 rounded-2xl px-4 py-3']"
:loading="isCreatingSession"
@click="emit('newSession')"
>
<span aria-hidden="true" :class="['i-solar:pen-new-square-outline size-5']" />
{{ t('stage.chat.sessions.new') }}
</Button>
<ScrollableArea :class="['min-h-0 max-h-[calc(80dvh-12rem)]']" :viewport-class="['p-1']">
<div v-if="rows.length === 0" :class="['min-h-40 flex flex-col items-center justify-center gap-3 text-sm text-neutral-500 dark:text-neutral-400']">
<span aria-hidden="true" :class="['i-solar:dialog-2-outline size-8 text-neutral-400']" />
{{ t('stage.chat.sessions.empty') }}
</div>
<ul v-else :class="['m-0 list-none space-y-2 p-0']">
<li
v-for="row in rows"
:key="row.meta.sessionId"
:class="[
'overflow-hidden rounded-2xl',
row.isActive ? 'bg-primary-50 dark:bg-primary-900/25' : 'bg-white dark:bg-neutral-800/60',
]"
>
<div :class="['flex items-center pr-1']">
<BasicButton
size="unset"
:aria-current="row.isActive ? 'true' : undefined"
:class="[
'session-select min-h-20 min-w-0 flex-1 rounded-2xl px-3 py-3 text-left',
'focus-visible:outline-2 focus-visible:outline-primary-500',
]"
@click="emit('selectSession', row.meta.sessionId)"
>
<span
aria-hidden="true"
:class="[
'size-5 shrink-0',
row.isActive ? 'i-solar:check-circle-bold text-primary-500' : 'i-solar:chat-line-outline text-neutral-400',
]"
/>
<span :class="['min-w-0 flex-1']">
<span :class="['block truncate text-sm font-medium']">{{ row.preview }}</span>
<span :class="['mt-1 flex items-center gap-2 text-xs font-normal text-neutral-500 dark:text-neutral-400']">
<span>{{ row.updatedAtLabel }}</span>
<span v-if="row.isActive" :class="['text-primary-600 dark:text-primary-300']">{{ t('stage.chat.sessions.current') }}</span>
<span v-if="row.meta.cloudChatId" role="img" :aria-label="t('stage.chat.sessions.cloud-badge')" :title="t('stage.chat.sessions.cloud-badge')" :class="['i-solar:cloud-check-outline size-4 shrink-0']" />
</span>
</span>
</BasicButton>
<GhostButton
size="unset"
:class="['size-11 shrink-0 rounded-xl text-neutral-400 hover:bg-red-500/10 hover:text-red-500']"
:aria-label="`${t('stage.chat.sessions.delete')}: ${row.preview}`"
:title="t('stage.chat.sessions.delete')"
:aria-expanded="pendingDeletion === row.meta.sessionId"
@click="requestDeletion(row.meta.sessionId, $event)"
>
<span aria-hidden="true" :class="['i-solar:trash-bin-trash-outline size-5']" />
</GhostButton>
</div>
<div v-if="pendingDeletion === row.meta.sessionId" :class="['mx-3 border-t border-neutral-200 py-3 dark:border-neutral-700']">
<p role="status" :class="['mb-3 text-sm text-neutral-600 dark:text-neutral-300']">
{{ t('stage.chat.sessions.confirm-delete') }}
</p>
<div :class="['flex justify-end gap-2']">
<Button size="unset" :class="['min-h-11 px-4']" @click="cancelDeletion">
{{ t('stage.chat.sessions.cancel') }}
</Button>
<Button size="unset" color="red" variant="secondary" :class="['min-h-11 px-4']" @click="emit('deleteSession', row.meta.sessionId); pendingDeletion = undefined">
{{ t('stage.chat.sessions.delete') }}
</Button>
</div>
</div>
</li>
</ul>
</ScrollableArea>
</div>
</template>
<style scoped>
.session-select :deep(.basic-button-content) {
width: 100%;
min-width: 0;
gap: 0.75rem;
}
</style>
@@ -0,0 +1,45 @@
import { BottomDrawer, GhostButton } from '@proj-airi/ui'
import { describe, expect, it } from 'vitest'
import { render } from 'vitest-browser-vue'
import { userEvent } from 'vitest/browser'
import { defineComponent, ref } from 'vue'
const Harness = defineComponent({
components: { BottomDrawer, GhostButton },
setup() {
return { open: ref(false), count: ref(0), closed: ref(0) }
},
template: `
<BottomDrawer v-model="open" title="Stage" @after-close="closed++">
<template #trigger><GhostButton>More</GhostButton></template>
<GhostButton @click="count++">Change appearance</GhostButton>
<output aria-label="Action count">{{ count }}</output>
</BottomDrawer>
<output aria-label="Completed dismissals">{{ closed }}</output>
`,
})
describe('mobile tools drawer', () => {
// https://github.com/moeru-ai/airi/issues/2085
it('keeps action clicks independent of drag dismissal for Issue #2085', async () => {
// ROOT CAUSE:
// Dragging from action controls can dismiss a Vaul sheet before click runs.
// The shared drawer restricts drag initiation to its handle.
const screen = await render(Harness)
await screen.getByRole('button', { name: 'More' }).click()
await screen.getByRole('button', { name: 'Change appearance' }).click()
await screen.getByRole('button', { name: 'Change appearance' }).click()
await expect.element(screen.getByLabelText('Action count')).toHaveTextContent('2')
await expect.element(screen.getByRole('dialog', { name: 'Stage' })).toBeVisible()
await expect.element(screen.getByLabelText('Completed dismissals')).toHaveTextContent('0')
})
it('restores trigger focus and reports dismissal once', async () => {
const screen = await render(Harness)
await screen.getByRole('button', { name: 'More' }).click()
await expect.element(screen.getByRole('button', { name: 'Close' })).not.toBeInTheDocument()
await userEvent.keyboard('{Escape}')
await expect.element(screen.getByLabelText('Completed dismissals')).toHaveTextContent('1')
await expect.element(screen.getByRole('button', { name: 'More' })).toHaveFocus()
})
})
+16
View File
@@ -1,17 +1,33 @@
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'
import { loadEnv } from 'vite'
import { defineConfig } from 'vitest/config'
import { sharedUnoConfig } from '../../uno.config'
export default defineConfig({
root: import.meta.dirname,
plugins: [
Info(),
Vue(),
UnoCSS({
// Browser tests use product styles, not Histoire's hover-preview variants.
...sharedUnoConfig(),
configFile: false,
// Vitest loads components after the stylesheet. Scan their source before
// the initial CSS response instead of relying on Vite's HMR updates.
content: {
filesystem: [
`${import.meta.dirname}/src/**/*.vue`,
`${import.meta.dirname}/../ui/src/**/*.vue`,
],
},
}),
],
test: {
env: loadEnv('test', cwd(), ''),
+1
View File
@@ -30,6 +30,7 @@
"@vueuse/core": "catalog:",
"floating-vue": "catalog:",
"reka-ui": "catalog:",
"vaul-vue": "catalog:",
"vue": "catalog:"
},
"devDependencies": {
@@ -44,7 +44,9 @@ function onPaste(e: ClipboardEvent) {
// javascript - Creating a textarea with auto-resize - Stack Overflow
// https://stackoverflow.com/questions/454202/creating-a-textarea-with-auto-resize
watch(input, () => {
textareaHeight.value = 'auto'
// An explicit baseline prevents a flex parent's minimum height from
// stretching the textarea before scrollHeight measures its content.
textareaHeight.value = props.defaultHeight || 'auto'
requestAnimationFrame(() => {
if (!textareaRef.value)
return
@@ -53,11 +55,7 @@ watch(input, () => {
return
}
// NOTICE: not sure why 4px is required but if not added, when
// input happened and placeholder now disappeared, the textarea will shrink
// a little bit and cause the input box to shake.
// TODO: find out the root cause and remove this magic number, or at least
// reference a more specific source.
// scrollHeight includes padding but excludes the two 2px borders.
textareaHeight.value = `${textareaRef.value.scrollHeight + 4}px`
})
}, { immediate: true })
@@ -67,6 +65,7 @@ watch(input, () => {
<textarea
ref="textareaRef"
v-model="input"
rows="1"
:style="{ height: textareaHeight }"
@keydown="onKeyDown"
@paste="onPaste"
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { DrawerContent, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTitle, DrawerTrigger } from 'vaul-vue'
import { watch } from 'vue'
const props = withDefaults(defineProps<{
/** Names the dialog for both the visible heading and assistive technology. */
title: string
/** Sets the minimum drawer height while content can still expand to the shared maximum. @default 'content' */
minimumHeight?: 'content' | 'half'
}>(), {
minimumHeight: 'content',
})
const emit = defineEmits<{
/** Fires after dismissal completes, so a consumer can open another modal. */
afterClose: []
/** Cancel when focus will move directly into another modal. */
closeAutoFocus: [event: Event]
}>()
const open = defineModel<boolean>({ default: false })
// Vaul can emit a closed animation event during initialization. Only a real
// open-to-closed transition completes dismissal; reopening cancels that handoff.
let closing = false
watch(open, (value, previous) => {
closing = !value && previous
}, { flush: 'sync' })
function finishAnimation(value: boolean) {
if (value || open.value || !closing)
return
closing = false
emit('afterClose')
}
</script>
<template>
<!-- Only the handle owns drag gestures; menu actions and scrolling keep native pointer behavior. -->
<DrawerRoot v-model:open="open" handle-only @animation-end="finishAnimation">
<DrawerTrigger v-if="$slots.trigger" as-child>
<slot name="trigger" />
</DrawerTrigger>
<DrawerPortal>
<DrawerOverlay :class="['fixed inset-0 z-[9999] bg-black/35']" />
<DrawerContent
:aria-describedby="undefined"
:class="[
'pointer-events-auto fixed inset-x-0 bottom-0 z-[9999] mx-auto max-w-lg',
'max-h-[90dvh] flex flex-col rounded-t-[32px] outline-none shadow-xl',
'bg-neutral-50 text-neutral-900 dark:bg-neutral-900 dark:text-neutral-100',
'pb-[max(1rem,env(safe-area-inset-bottom))]',
'motion-reduce:animate-none motion-reduce:transition-none',
props.minimumHeight === 'half' ? 'min-h-[50dvh]' : undefined,
]"
@close-auto-focus="emit('closeAutoFocus', $event)"
>
<div :class="['shrink-0 px-5 pt-4']">
<DrawerHandle :class="['mb-3 bg-neutral-300 dark:bg-neutral-600']" />
<div :class="['mb-5 pt-2']">
<DrawerTitle :class="['text-xl font-semibold tracking-tight']">
{{ props.title }}
</DrawerTitle>
</div>
</div>
<div :class="['min-h-0 overflow-y-auto overscroll-contain px-5']">
<slot />
</div>
</DrawerContent>
</DrawerPortal>
</DrawerRoot>
</template>
@@ -1,3 +1,4 @@
export { default as BottomDrawer } from './bottom-drawer.vue'
export { default as Collapsible } from './collapsible.vue'
export { default as Screen } from './screen.vue'
export { default as ScrollableArea } from './scrollable-area.vue'
+3
View File
@@ -5232,6 +5232,9 @@ importers:
reka-ui:
specifier: 'catalog:'
version: 2.10.3(vue@3.5.41(typescript@6.0.3))
vaul-vue:
specifier: 'catalog:'
version: 0.4.1(reka-ui@2.10.3(vue@3.5.41(typescript@6.0.3)))(vue@3.5.41(typescript@6.0.3))
vue:
specifier: 'catalog:'
version: 3.5.41(typescript@6.0.3)