+ {{ t('stage.chat.sessions.title') }}
+
+
-
-
-
- {{ t('stage.chat.sessions.title') }}
-
-
-
-
-
- {{ t('stage.chat.sessions.empty') }}
-
-
-
-
-
-
-
diff --git a/packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.browser.test.ts b/packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.browser.test.ts
index 301b399e1..b432d985b 100644
--- a/packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.browser.test.ts
+++ b/packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.browser.test.ts
@@ -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')
diff --git a/packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue b/packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue
index e0f107d4a..30dba8e26 100644
--- a/packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue
+++ b/packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue
@@ -1,9 +1,9 @@
+
+
+
+
+
+
+
+
+
+ {{ t('stage.chat.sessions.empty') }}
+
+
+
+
+
+
+
diff --git a/packages/stage-ui/src/components/scenarios/dialogs/bottom-drawer.browser.test.ts b/packages/stage-ui/src/components/scenarios/dialogs/bottom-drawer.browser.test.ts
new file mode 100644
index 000000000..ca818e6f5
--- /dev/null
+++ b/packages/stage-ui/src/components/scenarios/dialogs/bottom-drawer.browser.test.ts
@@ -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: `
+
+ More
+ Change appearance
+
+
+
+ `,
+})
+
+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()
+ })
+})
diff --git a/packages/stage-ui/vitest.config.ts b/packages/stage-ui/vitest.config.ts
index 38c5d44d8..f2e7befd7 100644
--- a/packages/stage-ui/vitest.config.ts
+++ b/packages/stage-ui/vitest.config.ts
@@ -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(), ''),
diff --git a/packages/ui/package.json b/packages/ui/package.json
index f83664ca8..d5ebb7f8d 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -30,6 +30,7 @@
"@vueuse/core": "catalog:",
"floating-vue": "catalog:",
"reka-ui": "catalog:",
+ "vaul-vue": "catalog:",
"vue": "catalog:"
},
"devDependencies": {
diff --git a/packages/ui/src/components/form/textarea/basic-text-area.vue b/packages/ui/src/components/form/textarea/basic-text-area.vue
index ef592fdb0..348bad2cf 100644
--- a/packages/ui/src/components/form/textarea/basic-text-area.vue
+++ b/packages/ui/src/components/form/textarea/basic-text-area.vue
@@ -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, () => {