feat(stage-layouts): simplify mobile stage controls (#2472)
Signed-off-by: RainbowBird <git@luoling.moe>
This commit is contained in:
@@ -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]"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 |
|
||||
|------|------|---------|-------------|
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
+21
-3
@@ -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)
|
||||
})
|
||||
+45
-6
@@ -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"
|
||||
/>
|
||||
<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']">
|
||||
<DialogTitle :class="['mb-5 text-xl font-semibold tracking-tight']">
|
||||
{{ 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>
|
||||
<SessionsList
|
||||
:rows="rows"
|
||||
:is-creating-session="isCreatingSession"
|
||||
@new-session="emit('newSession')"
|
||||
@select-session="emit('selectSession', $event)"
|
||||
@delete-session="emit('deleteSession', $event)"
|
||||
/>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
|
||||
+7
@@ -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,6 +103,7 @@ 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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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(), ''),
|
||||
|
||||
@@ -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'
|
||||
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user