feat(stage-tamagotchi,stage-ui,ui): input should not flink, added retry for errored item, adjusted button
This commit is contained in:
@@ -10,9 +10,5 @@
|
||||
"dependencies": {
|
||||
"@moeru/std": "catalog:",
|
||||
"uncrypto": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/browser-playwright": "catalog:vitest",
|
||||
"vitest": "catalog:vitest"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
chat:
|
||||
actions:
|
||||
retry: Retry
|
||||
message:
|
||||
character-name:
|
||||
airi: AIRI
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
chat:
|
||||
actions:
|
||||
retry: Volver a intentar
|
||||
message:
|
||||
character-name:
|
||||
airi: AIRI
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
chat:
|
||||
actions:
|
||||
retry: Réessayer
|
||||
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,4 +1,6 @@
|
||||
chat:
|
||||
actions:
|
||||
retry: Повторить
|
||||
message:
|
||||
character-name:
|
||||
airi: AIRI
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user