feat(stage-tamagotchi,stage-ui,ui): input should not flink, added retry for errored item, adjusted button

This commit is contained in:
Neko Ayaka
2026-04-23 01:09:46 +08:00
parent ee86608195
commit a3249ccd68
27 changed files with 717 additions and 62 deletions
@@ -10,9 +10,5 @@
"dependencies": {
"@moeru/std": "catalog:",
"uncrypto": "catalog:"
},
"devDependencies": {
"@vitest/browser-playwright": "catalog:vitest",
"vitest": "catalog:vitest"
}
}
+2
View File
@@ -1,4 +1,6 @@
chat:
actions:
retry: Retry
message:
character-name:
airi: AIRI
+2
View File
@@ -1,4 +1,6 @@
chat:
actions:
retry: Volver a intentar
message:
character-name:
airi: AIRI
+2
View File
@@ -1,4 +1,6 @@
chat:
actions:
retry: Réessayer
message:
character-name:
airi: AIRI
+2
View File
@@ -1,4 +1,6 @@
chat:
actions:
retry: 再試行
message:
character-name:
airi: AIRI
+2
View File
@@ -1,4 +1,6 @@
chat:
actions:
retry: 다시 시도
message:
character-name:
airi: AIRI
+2
View File
@@ -1,4 +1,6 @@
chat:
actions:
retry: Повторить
message:
character-name:
airi: AIRI
+2
View File
@@ -1,4 +1,6 @@
chat:
actions:
retry: Thử lại
message:
character-name:
airi: AIRI
@@ -1,4 +1,6 @@
chat:
actions:
retry: 重试
message:
character-name:
airi: AIRI
@@ -1,4 +1,6 @@
chat:
actions:
retry: 重試
message:
character-name:
airi: AIRI
+1
View File
@@ -195,6 +195,7 @@
"unplugin-info": "catalog:",
"unplugin-yaml": "^4.1.0",
"vite": "^6.4.2",
"vitest-browser-vue": "catalog:",
"vue": "catalog:",
"vue-tsc": "^3.2.6"
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { createChatActionMenuItems } from './menu-items'
/**
* @example
* describe('createChatActionMenuItems', () => {
* it('includes retry between copy and delete when retry is available', () => {})
* })
*/
describe('createChatActionMenuItems', () => {
/**
* @example
* it('includes retry between copy and delete when retry is available', () => {
* const items = createChatActionMenuItems({ canCopy: true, canRetry: true, canDelete: true })
* expect(items.map(item => item.action)).toEqual(['copy', 'retry', 'delete'])
* })
*/
it('includes retry between copy and delete when retry is available', () => {
const items = createChatActionMenuItems({
canCopy: true,
canRetry: true,
canDelete: true,
})
expect(items.map(item => item.action)).toEqual(['copy', 'retry', 'delete'])
expect(items[1]?.label).toBe('Retry')
})
/**
* @example
* it('omits retry when retry is unavailable', () => {
* const items = createChatActionMenuItems({ canCopy: true, canRetry: false, canDelete: true })
* expect(items.map(item => item.action)).toEqual(['copy', 'delete'])
* })
*/
it('omits retry when retry is unavailable', () => {
const items = createChatActionMenuItems({
canCopy: true,
canRetry: false,
canDelete: true,
})
expect(items.map(item => item.action)).toEqual(['copy', 'delete'])
})
})
@@ -1,33 +1,3 @@
export type ChatActionMenuAction = 'copy' | 'delete'
export interface ChatActionMenuItem {
action: ChatActionMenuAction
label: string
icon: string
danger?: boolean
}
export function createChatActionMenuItems(options: {
canCopy: boolean
canDelete: boolean
}): ChatActionMenuItem[] {
return [
options.canCopy
? {
action: 'copy',
label: 'Copy',
icon: 'i-solar:copy-bold',
}
: null,
options.canDelete
? {
action: 'delete',
label: 'Delete',
icon: 'i-solar:trash-bin-minimalistic-bold',
danger: true,
}
: null,
].filter(Boolean) as ChatActionMenuItem[]
}
export { default as ChatActionMenu } from './index.vue'
export type { ChatActionMenuAction, ChatActionMenuItem } from './menu-items'
export { createChatActionMenuItems } from './menu-items'
@@ -21,6 +21,7 @@ import {
DropdownMenuTrigger,
} from 'reka-ui'
import { computed, inject, reactive, ref, shallowRef, toRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWebHaptics } from 'web-haptics/vue'
import { createChatActionMenuItems } from '.'
@@ -30,12 +31,14 @@ import { chatScrollContainerKey } from '../../constants'
const props = withDefaults(defineProps<{
canCopy?: boolean
canRetry?: boolean
canDelete?: boolean
copyText?: string
menuLabel?: string
placement?: 'left' | 'right'
}>(), {
canCopy: true,
canRetry: false,
canDelete: true,
copyText: '',
menuLabel: 'Message actions',
@@ -44,6 +47,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
(e: 'copy'): void
(e: 'retry'): void
(e: 'delete'): void
}>()
defineSlots<{
@@ -79,11 +83,14 @@ const bottomSentinelVisible = useElementVisibility(bottomSentinelRef, {
const { trigger } = useWebHaptics()
const { isMobile } = useBreakpoints()
const { t } = useI18n()
const shouldDisableDropdownMenu = computed(() => (isStageWeb() || isStageCapacitor()) && isMobile.value)
const menuItems = computed(() => createChatActionMenuItems({
canCopy: props.canCopy && props.copyText.trim().length > 0,
canRetry: props.canRetry,
canDelete: props.canDelete,
retryLabel: t('stage.chat.actions.retry'),
}))
const hasMenuItems = computed(() => menuItems.value.length > 0)
const forceVisible = computed(() => contextMenuOpen.value)
@@ -143,6 +150,11 @@ async function handleAction(action: ChatActionMenuAction) {
return
}
if (action === 'retry') {
emit('retry')
return
}
emit('delete')
}
@@ -0,0 +1,40 @@
export type ChatActionMenuAction = 'copy' | 'retry' | 'delete'
export interface ChatActionMenuItem {
action: ChatActionMenuAction
label: string
icon: string
danger?: boolean
}
export function createChatActionMenuItems(options: {
canCopy: boolean
canRetry: boolean
canDelete: boolean
retryLabel?: string
}): ChatActionMenuItem[] {
return [
options.canCopy
? {
action: 'copy',
label: 'Copy',
icon: 'i-solar:copy-bold',
}
: null,
options.canRetry
? {
action: 'retry',
label: options.retryLabel ?? 'Retry',
icon: 'i-solar:refresh-bold',
}
: null,
options.canDelete
? {
action: 'delete',
label: 'Delete',
icon: 'i-solar:trash-bin-minimalistic-bold',
danger: true,
}
: null,
].filter(Boolean) as ChatActionMenuItem[]
}
@@ -2,6 +2,7 @@
import type { ChatHistoryItem, ErrorMessage } from '../../../../types/chat'
import { isStageCapacitor, isStageWeb } from '@proj-airi/stage-shared'
import { Button } from '@proj-airi/ui'
import { computed } from 'vue'
import { MarkdownRenderer } from '../../../markdown'
@@ -11,15 +12,19 @@ import { ChatActionMenu } from './action-menu'
const props = withDefaults(defineProps<{
message: ErrorMessage
label: string
retryLabel?: string
canRetry?: boolean
showPlaceholder?: boolean
variant?: 'desktop' | 'mobile'
}>(), {
canRetry: false,
showPlaceholder: false,
variant: 'desktop',
})
const emit = defineEmits<{
(e: 'copy'): void
(e: 'retry'): void
(e: 'delete'): void
}>()
@@ -32,20 +37,30 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat
</script>
<template>
<div flex :class="variant === 'mobile' ? 'mr-0' : 'mr-12'">
<div
:class="[
'flex flex-col',
variant === 'mobile' ? 'mr-0' : 'mr-12',
]"
>
<ChatActionMenu
:copy-text="copyText"
:can-delete="!showPlaceholder"
:can-retry="canRetry && !showPlaceholder"
@copy="emit('copy')"
@retry="emit('retry')"
@delete="emit('delete')"
>
<template #default="{ setMeasuredElement }">
<div
:ref="setMeasuredElement"
flex="~ col" shadow="sm violet-200/50 dark:none"
min-w-20 rounded-xl h="unset <sm:fit"
:class="[
boxClasses,
'relative',
'flex flex-col',
'min-w-20 rounded-xl',
'h-unset <sm:h-fit',
'shadow-sm shadow-violet-200/50 dark:shadow-none',
'bg-violet-100/80 dark:bg-violet-950/80',
(isStageWeb() || isStageCapacitor()) && props.variant === 'mobile' ? 'select-none sm:select-auto' : '',
]"
@@ -65,5 +80,19 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat
</div>
</template>
</ChatActionMenu>
<div
v-if="canRetry && !showPlaceholder"
:class="[
'self-end mt-1',
]"
>
<Button
size="sm"
variant="ghost"
shape="square"
icon="i-solar:refresh-bold"
@click="emit('retry')"
/>
</div>
</div>
</template>
@@ -0,0 +1,136 @@
import type { ChatHistoryItem } from '../../../../types/chat'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-vue'
import { defineComponent, shallowRef } from 'vue'
import { createI18n } from 'vue-i18n'
import ChatHistory from './history.vue'
vi.mock('../composables/use-chat-history-scroll', () => ({
useChatHistoryScroll: () => undefined,
}))
vi.mock('../../../markdown', () => ({
MarkdownRenderer: defineComponent({
name: 'MarkdownRendererStub',
props: {
content: {
type: String,
default: '',
},
},
template: '<div>{{ content }}</div>',
}),
}))
function createTestI18n() {
return createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
stage: {
chat: {
actions: {
retry: 'Retry',
},
message: {
'character-name': {
'airi': 'AIRI',
'core-system': 'System',
'you': 'You',
},
},
},
},
},
},
})
}
function createHarness(messages: ChatHistoryItem[]) {
return defineComponent({
name: 'ChatHistoryRetryHarness',
components: {
ChatHistory,
},
setup() {
const lastRetryIndex = shallowRef('none')
function handleRetryMessage(payload: { index: number }) {
lastRetryIndex.value = String(payload.index)
}
return {
handleRetryMessage,
lastRetryIndex,
messages,
}
},
template: `
<div>
<ChatHistory
:messages="messages"
@retry-message="handleRetryMessage"
/>
<output aria-label="retry-index">{{ lastRetryIndex }}</output>
</div>
`,
})
}
/**
* @example
* describe('ChatHistory retry actions', () => {
* it('emits retry-message when the retry button is clicked for an error after a user message', async () => {})
* })
*/
describe('chatHistory retry actions', () => {
/**
* @example
* it('emits retry-message when the retry button is clicked for an error after a user message', async () => {
* const screen = await render(createHarness(messages), { global: { plugins: [createTestI18n()] } })
* await screen.getByRole('button', { name: 'Retry' }).click()
* await expect.element(screen.getByLabelText('retry-index')).toHaveTextContent('1')
* })
*/
it('emits retry-message when the retry button is clicked for an error after a user message', async () => {
const messages: ChatHistoryItem[] = [
{ role: 'user', content: 'hello' },
{ role: 'error', content: 'Remote sent 400 response' },
]
const screen = await render(createHarness(messages), {
global: {
plugins: [createTestI18n()],
},
})
await screen.getByRole('button', { name: 'Retry' }).click()
await expect.element(screen.getByLabelText('retry-index')).toHaveTextContent('1')
})
/**
* @example
* it('does not render the retry button when the error is not preceded by a user message', async () => {
* const screen = await render(createHarness(messages), { global: { plugins: [createTestI18n()] } })
* expect(document.body.textContent).not.toContain('Retry')
* })
*/
it('does not render the retry button when the error is not preceded by a user message', async () => {
const messages: ChatHistoryItem[] = [
{ role: 'assistant', content: 'hello', slices: [], tool_results: [] },
{ role: 'error', content: 'Remote sent 400 response' },
]
await render(createHarness(messages), {
global: {
plugins: [createTestI18n()],
},
})
expect(document.body.textContent).not.toContain('Retry')
})
})
@@ -19,6 +19,7 @@ const props = withDefaults(defineProps<{
assistantLabel?: string
userLabel?: string
errorLabel?: string
retryLabel?: string
variant?: 'desktop' | 'mobile'
}>(), {
sending: false,
@@ -28,6 +29,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
(e: 'copyMessage', payload: { message: ChatHistoryItem, index: number, key: string | number }): void
(e: 'deleteMessage', payload: { message: ChatHistoryItem, index: number, key: string | number }): void
(e: 'retryMessage', payload: { message: ChatHistoryItem, index: number, key: string | number }): void
}>()
const chatHistoryRef = ref<HTMLDivElement>()
@@ -38,6 +40,7 @@ const labels = computed(() => ({
assistant: props.assistantLabel ?? t('stage.chat.message.character-name.airi'),
user: props.userLabel ?? t('stage.chat.message.character-name.you'),
error: props.errorLabel ?? t('stage.chat.message.character-name.core-system'),
retry: props.retryLabel ?? t('stage.chat.actions.retry'),
}))
const streaming = computed<ChatAssistantMessage & { context?: ContextMessage } & { createdAt?: number }>(() => props.streamingMessage ?? { role: 'assistant', content: '', slices: [], tool_results: [], createdAt: Date.now() })
@@ -86,6 +89,14 @@ function emitDeleteMessage(message: ChatHistoryItem, index: number) {
key: getChatHistoryItemKey(message, index),
})
}
function emitRetryMessage(message: ChatHistoryItem, index: number) {
emit('retryMessage', {
message,
index,
key: getChatHistoryItemKey(message, index),
})
}
</script>
<template>
@@ -100,9 +111,12 @@ function emitDeleteMessage(message: ChatHistoryItem, index: number) {
v-if="message.role === 'error'"
:message="message"
:label="labels.error"
:retry-label="labels.retry"
:can-retry="renderMessages[index - 1]?.role === 'user'"
:show-placeholder="sending && index === renderMessages.length - 1"
:variant="variant"
@copy="emitCopyMessage(message, index)"
@retry="emitRetryMessage(message, index)"
@delete="emitDeleteMessage(message, index)"
/>
<ChatAssistantItem
+34 -4
View File
@@ -1,14 +1,44 @@
import { join } from 'node:path'
import { cwd } from 'node:process'
import Vue from '@vitejs/plugin-vue'
import { playwright } from '@vitest/browser-playwright'
import { loadEnv } from 'vite'
import { defineConfig } from 'vitest/config'
export default defineConfig(({ mode }) => {
return ({
return {
test: {
include: ['src/**/*.test.ts'],
env: loadEnv(mode, join(cwd(), 'packages', 'stage-ui'), ''),
projects: [
{
extends: true,
test: {
name: 'node',
include: ['src/**/*.test.ts'],
exclude: ['src/**/*.browser.test.ts'],
env: loadEnv(mode, join(cwd(), 'packages', 'stage-ui'), ''),
},
},
{
extends: true,
plugins: [
Vue(),
],
test: {
name: 'browser',
include: ['**/*.browser.{spec,test}.ts'],
exclude: ['**/node_modules/**'],
browser: {
enabled: true,
provider: playwright(),
instances: [
{ browser: 'chromium' },
],
},
},
},
],
},
})
}
})
@@ -53,7 +53,12 @@ watch(input, () => {
return
}
textareaHeight.value = `${textareaRef.value.scrollHeight}px`
// 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.
textareaHeight.value = `${textareaRef.value.scrollHeight + 4}px`
})
}, { immediate: true })
</script>
+3 -1
View File
@@ -109,6 +109,7 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
'pure': {
default: {
default: [
'rounded-lg',
'bg-transparent',
'text-neutral-900 dark:text-neutral-50',
'!px-0 !py-0',
@@ -118,10 +119,11 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
'ghost': {
default: {
default: [
'rounded-lg',
'bg-transparent',
'hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50',
'text-neutral-500 dark:text-neutral-400',
'focus:ring-2 focus:ring-neutral-300/30 dark:focus:ring-neutral-600/30',
'focus:ring-none',
],
},
},