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

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