feat(stage-ui): add swipe to reply for chat messages (#2489)
This commit is contained in:
@@ -190,6 +190,70 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
expect(providerUserMessage).not.toHaveProperty('tools')
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// The composer encoded a reply as localized Markdown inside the user text.
|
||||
// The stored message therefore lost the relation to the replied message.
|
||||
//
|
||||
// We fixed this by storing the reply message id and projecting its text only
|
||||
// for the provider request.
|
||||
it('stores a native reply relation without changing the user text', async () => {
|
||||
const harness = createHarness()
|
||||
harness.sessionMessages['session-1']?.push({
|
||||
role: 'assistant',
|
||||
content: 'Earlier answer',
|
||||
slices: [{ type: 'text', text: 'Earlier answer' }],
|
||||
tool_results: [],
|
||||
id: 'assistant-earlier',
|
||||
})
|
||||
|
||||
await harness.runtime.ingest('My follow-up', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
replyToMessageId: 'assistant-earlier',
|
||||
})
|
||||
|
||||
const storedUserMessage = harness.sessionMessages['session-1']?.find(message => message.id === 'user-id')
|
||||
const providerMessages = harness.stream.mock.calls[0]?.[2]
|
||||
const providerUserMessage = providerMessages?.at(-1)
|
||||
|
||||
expect(storedUserMessage).toMatchObject({
|
||||
role: 'user',
|
||||
content: 'My follow-up',
|
||||
replyToMessageId: 'assistant-earlier',
|
||||
})
|
||||
expect(providerUserMessage).toMatchObject({
|
||||
role: 'user',
|
||||
content: '[2026-04-25 18:47] [Replying to: Earlier answer]\nMy follow-up',
|
||||
})
|
||||
expect(providerUserMessage).not.toHaveProperty('replyToMessageId')
|
||||
})
|
||||
|
||||
it('limits repeated reply text in the provider prompt', async () => {
|
||||
const harness = createHarness()
|
||||
harness.sessionMessages['session-1']?.push({
|
||||
role: 'assistant',
|
||||
content: 'a'.repeat(600),
|
||||
slices: [{ type: 'text', text: 'a'.repeat(600) }],
|
||||
tool_results: [],
|
||||
id: 'assistant-long-reply',
|
||||
})
|
||||
|
||||
await harness.runtime.ingest('My follow-up', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
replyToMessageId: 'assistant-long-reply',
|
||||
})
|
||||
|
||||
const providerMessages = harness.stream.mock.calls[0]?.[2]
|
||||
const providerUserMessage = providerMessages?.at(-1)
|
||||
|
||||
expect(providerUserMessage).toMatchObject({
|
||||
role: 'user',
|
||||
content: `[2026-04-25 18:47] [Replying to: ${'a'.repeat(479)}…]\nMy follow-up`,
|
||||
})
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// xsAI kept the assistant tool call and tool result in its private message copy.
|
||||
@@ -729,6 +793,92 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
await firstSend
|
||||
})
|
||||
|
||||
// https://github.com/moeru-ai/airi/pull/2489#discussion_r3967818108
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// A queued send kept the reply target captured by the composer. Deleting that
|
||||
// target did not change the session generation, so the queued message stored a
|
||||
// dangling relation and projected the missing id into the provider prompt.
|
||||
//
|
||||
// The send must revalidate the relation against current session history after
|
||||
// asynchronous composition and immediately before append.
|
||||
it('drops a queued reply relation when its target is deleted before append', async () => {
|
||||
const harness = createHarness()
|
||||
let queuedSendContext: ChatHistoryItem | undefined
|
||||
let releaseQueuedComposition: (() => void) | undefined
|
||||
harness.runtime.hooks.onBeforeMessageComposed(async (message, context) => {
|
||||
if (message !== 'send without stale reply')
|
||||
return
|
||||
|
||||
queuedSendContext = context.message
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseQueuedComposition = resolve
|
||||
})
|
||||
})
|
||||
harness.sessionMessages['session-1']?.push({
|
||||
role: 'assistant',
|
||||
content: 'Reply target',
|
||||
slices: [{ type: 'text', text: 'Reply target' }],
|
||||
tool_results: [],
|
||||
id: 'deleted-reply-target',
|
||||
})
|
||||
let releaseFirstSend: (() => void) | undefined
|
||||
harness.stream.mockImplementationOnce(async (_model, _chatProvider, _messages, options) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirstSend = resolve
|
||||
})
|
||||
await options?.onStreamEvent?.({ type: 'finish', finishReason: 'stop' })
|
||||
})
|
||||
|
||||
const firstSend = harness.runtime.ingest('hold queue', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
})
|
||||
const queuedReply = harness.runtime.ingest('send without stale reply', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
replyToMessageId: 'deleted-reply-target',
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(harness.stream).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(harness.runtime.getPendingQueuedSendCount()).toBe(1)
|
||||
})
|
||||
releaseFirstSend?.()
|
||||
await vi.waitFor(() => {
|
||||
expect(releaseQueuedComposition).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
const sessionMessages = harness.sessionMessages['session-1']
|
||||
if (!sessionMessages)
|
||||
throw new Error('Expected the active test session to exist')
|
||||
|
||||
harness.sessionMessages['session-1'] = sessionMessages
|
||||
.filter(message => message.id !== 'deleted-reply-target')
|
||||
releaseQueuedComposition?.()
|
||||
|
||||
await firstSend
|
||||
await queuedReply
|
||||
|
||||
const storedReply = harness.sessionMessages['session-1']
|
||||
?.find(message => message.role === 'user' && message.content === 'send without stale reply')
|
||||
const providerUserMessage = harness.stream.mock.calls[1]?.[2].at(-1)
|
||||
const syncedUserMessage = (harness.userAppended.at(-1) as { message?: ChatHistoryItem } | undefined)?.message
|
||||
|
||||
expect(storedReply).toBeDefined()
|
||||
expect(storedReply).not.toHaveProperty('replyToMessageId')
|
||||
expect(providerUserMessage).toMatchObject({
|
||||
role: 'user',
|
||||
content: '[2026-04-25 18:47] send without stale reply',
|
||||
})
|
||||
expect(syncedUserMessage).toBeDefined()
|
||||
expect(syncedUserMessage).not.toHaveProperty('replyToMessageId')
|
||||
expect(queuedSendContext).toBeDefined()
|
||||
expect(queuedSendContext).not.toHaveProperty('replyToMessageId')
|
||||
})
|
||||
|
||||
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3714754876
|
||||
it('suppresses completion hooks when an active send session is deleted for Issue #2085', async () => {
|
||||
// ROOT CAUSE:
|
||||
|
||||
@@ -16,6 +16,12 @@ import { categorizeResponse, createStreamingCategorizer } from './response-categ
|
||||
|
||||
const REASONING_UI_FLUSH_CHUNK_SIZE = 24
|
||||
|
||||
/**
|
||||
* Caps repeated reply text in the model prompt. The referenced message remains
|
||||
* in history, so the prefix only needs enough text to identify it.
|
||||
*/
|
||||
const REPLY_PROMPT_REFERENCE_CHARACTER_LIMIT = 480
|
||||
|
||||
function prependTextToContent<T extends { content?: unknown }>(msg: T, text: string): T {
|
||||
const content = msg.content
|
||||
if (content === undefined)
|
||||
@@ -35,6 +41,54 @@ function prependTextToContent<T extends { content?: unknown }>(msg: T, text: str
|
||||
return msg
|
||||
}
|
||||
|
||||
function getMessageText(message: ChatHistoryItem): string {
|
||||
if (typeof message.content === 'string')
|
||||
return message.content
|
||||
|
||||
if (!Array.isArray(message.content))
|
||||
return ''
|
||||
|
||||
return message.content
|
||||
.filter(part => part.type === 'text')
|
||||
.map(part => part.text)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a model-only reference to the message selected by the user.
|
||||
*
|
||||
* @example
|
||||
* formatReplyPromptPrefix('message-1', new Map([
|
||||
* ['message-1', { id: 'message-1', role: 'user', content: 'Earlier turn' }],
|
||||
* ]))
|
||||
* // => '[Replying to: Earlier turn]\n'
|
||||
*/
|
||||
function formatReplyPromptPrefix(replyToMessageId: string | undefined, messagesById: Map<string, ChatHistoryItem>): string {
|
||||
if (!replyToMessageId)
|
||||
return ''
|
||||
|
||||
const target = messagesById.get(replyToMessageId)
|
||||
if (!target)
|
||||
return ''
|
||||
|
||||
const targetText = getMessageText(target).replace(/\s+/g, ' ').trim()
|
||||
const preview = targetText.length > REPLY_PROMPT_REFERENCE_CHARACTER_LIMIT
|
||||
? `${targetText.slice(0, REPLY_PROMPT_REFERENCE_CHARACTER_LIMIT - 1).trimEnd()}…`
|
||||
: targetText
|
||||
return preview
|
||||
? `[Replying to: ${preview}]\n`
|
||||
: `[Replying to message: ${replyToMessageId}]\n`
|
||||
}
|
||||
|
||||
function resolveReplyTargetId(replyToMessageId: string | undefined, messages: ChatHistoryItem[]): string | undefined {
|
||||
if (!replyToMessageId)
|
||||
return undefined
|
||||
|
||||
return messages.some(message => message.id === replyToMessageId)
|
||||
? replyToMessageId
|
||||
: undefined
|
||||
}
|
||||
|
||||
function cloneStreamingMessage(message: StreamingAssistantMessage): StreamingAssistantMessage {
|
||||
try {
|
||||
return structuredClone(message)
|
||||
@@ -62,6 +116,8 @@ export interface ChatOrchestratorSendOptions {
|
||||
toolReferences?: ChatToolReference[]
|
||||
/** Original transport input metadata used by bridge/devtools observers. */
|
||||
input?: ChatStreamEventContext['input']
|
||||
/** Message that the new user turn replies to in the target session. */
|
||||
replyToMessageId?: string
|
||||
/** Temperature for the LLM request. */
|
||||
temperature?: number
|
||||
/** Top_p for the LLM request. */
|
||||
@@ -431,13 +487,17 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
||||
|
||||
function buildProviderMessages(sessionMessagesForSend: ChatHistoryItem[]): Array<Message | ErrorMessage> {
|
||||
const nowTs = now()
|
||||
const messagesById = new Map(
|
||||
sessionMessagesForSend.flatMap(message => message.id ? [[message.id, message] as const] : []),
|
||||
)
|
||||
|
||||
return sessionMessagesForSend.flatMap<Message | ErrorMessage>((msg) => {
|
||||
const { context: _context, id: _id, createdAt: _createdAt, tools: _tools, ...withoutContext } = msg
|
||||
const { context: _context, id: _id, createdAt: _createdAt, replyToMessageId, tools: _tools, ...withoutContext } = msg
|
||||
const rawMessage = unwrapMessage(withoutContext)
|
||||
|
||||
if (rawMessage.role === 'user') {
|
||||
return [prependTextToContent(rawMessage, formatTimePrefix(getStablePromptTimestamp(msg, nowTs)))]
|
||||
const prefix = `${formatTimePrefix(getStablePromptTimestamp(msg, nowTs))}${formatReplyPromptPrefix(replyToMessageId, messagesById)}`
|
||||
return [prependTextToContent(rawMessage, prefix)]
|
||||
}
|
||||
|
||||
if (rawMessage.role === 'assistant') {
|
||||
@@ -471,6 +531,7 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
||||
deps.session.ensureSession(sessionId)
|
||||
|
||||
const existingSessionMessages = deps.session.getSessionMessages(sessionId)
|
||||
let replyToMessageId = resolveReplyTargetId(options.replyToMessageId, existingSessionMessages)
|
||||
const turnIndex = existingSessionMessages.filter(message => message.role === 'user').length + 1
|
||||
|
||||
// Activation measures whether a conversation reaches its first assistant
|
||||
@@ -494,7 +555,13 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
||||
const roundId = createId()
|
||||
const streamingMessageContext: ChatStreamEventContext = {
|
||||
turnId: roundId,
|
||||
message: { role: 'user', content: sendingMessage, createdAt: sendingCreatedAt, id: streamContextMessageId },
|
||||
message: {
|
||||
role: 'user',
|
||||
content: sendingMessage,
|
||||
createdAt: sendingCreatedAt,
|
||||
id: streamContextMessageId,
|
||||
...(replyToMessageId ? { replyToMessageId } : {}),
|
||||
},
|
||||
contexts: deps.context.snapshot(),
|
||||
composedMessage: [],
|
||||
input: options.input,
|
||||
@@ -581,11 +648,21 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
||||
if (shouldAbort())
|
||||
return
|
||||
|
||||
replyToMessageId = resolveReplyTargetId(
|
||||
options.replyToMessageId,
|
||||
deps.session.getSessionMessages(sessionId),
|
||||
)
|
||||
if (replyToMessageId)
|
||||
streamingMessageContext.message.replyToMessageId = replyToMessageId
|
||||
else
|
||||
delete streamingMessageContext.message.replyToMessageId
|
||||
|
||||
const userMessage = {
|
||||
role: 'user' as const,
|
||||
content: finalContent,
|
||||
createdAt: sendingCreatedAt,
|
||||
id: roundId,
|
||||
...(replyToMessageId ? { replyToMessageId } : {}),
|
||||
...(options.toolReferences?.length ? { tools: options.toolReferences } : {}),
|
||||
}
|
||||
deps.session.appendSessionMessage(sessionId, userMessage)
|
||||
|
||||
@@ -64,6 +64,8 @@ export type ChatHistoryItem = (ChatMessage | ErrorMessage) & {
|
||||
context?: ContextMessage
|
||||
createdAt?: number
|
||||
id?: string
|
||||
/** Message that this message replies to in the same chat session. */
|
||||
replyToMessageId?: string
|
||||
/** Tools selected for this message. The runtime rebuilds executors from these names. */
|
||||
tools?: ChatToolReference[]
|
||||
}
|
||||
|
||||
@@ -21,12 +21,18 @@ mobile-tools:
|
||||
hearing: Hearing
|
||||
chat:
|
||||
actions:
|
||||
reply: Reply
|
||||
retry: Retry
|
||||
send: Send message
|
||||
message:
|
||||
character-name:
|
||||
airi: AIRI
|
||||
core-system: Core System
|
||||
you: You
|
||||
reply:
|
||||
cancel: Cancel reply
|
||||
message: Message
|
||||
replying-to: Replying to {name}
|
||||
reasoning: Reasoning
|
||||
sessions:
|
||||
title: Conversations
|
||||
|
||||
@@ -21,12 +21,18 @@ mobile-tools:
|
||||
hearing: 听觉
|
||||
chat:
|
||||
actions:
|
||||
reply: 回复
|
||||
retry: 重试
|
||||
send: 发送消息
|
||||
message:
|
||||
character-name:
|
||||
airi: AIRI
|
||||
core-system: 核心系统
|
||||
you: 你
|
||||
reply:
|
||||
cancel: 取消回复
|
||||
message: 消息
|
||||
replying-to: 回复 {name}
|
||||
reasoning: 思考
|
||||
sessions:
|
||||
title: 对话
|
||||
|
||||
@@ -4,7 +4,7 @@ Own the Electron capture scenarios used to generate tamagotchi docs screenshots.
|
||||
|
||||
## Purpose
|
||||
|
||||
This package owns product-specific Electron scenario definitions and AIRI window/navigation helpers only. It depends on `@vishot/source-electron` for:
|
||||
This package owns product-specific Electron scenario definitions and AIRI window, navigation, and interaction helpers only. It depends on `@vishot/source-electron` for:
|
||||
|
||||
- the generic `defineScenario()` helper
|
||||
- the generic Electron capture context surface
|
||||
@@ -74,6 +74,29 @@ export default defineStageTamagotchiScenario({
|
||||
})
|
||||
```
|
||||
|
||||
### Gesture primitive
|
||||
|
||||
Use `gestures.swipe` to reproduce a direct touch swipe or a two-finger wheel pan:
|
||||
|
||||
```ts
|
||||
export default defineStageTamagotchiScenario({
|
||||
id: 'chat-swipe-reply',
|
||||
async run({ gestures, stageWindows }) {
|
||||
const chat = await stageWindows.waitFor('chat')
|
||||
const message = chat.page.locator('[data-swipeable]').last()
|
||||
|
||||
await gestures.swipe(message, {
|
||||
input: 'wheel',
|
||||
direction: 'left',
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Use `input: 'touch'` for the narrow-screen Pointer Event path. The primitive
|
||||
calculates the target center, splits the travel into samples, and waits for the
|
||||
gesture to settle.
|
||||
|
||||
## Scenario Layout
|
||||
|
||||
The docs workflow is organized as one section-based scenario module under `src/scenarios/demo-controls-settings-chat-websocket/`. The top-level `index.ts` orchestrates section manifests.
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('createStageTamagotchiScenarioContext', () => {
|
||||
expect(context.settingsWindow.goToRoute).toEqual(expect.any(Function))
|
||||
expect(context.dialogs.dismiss).toEqual(expect.any(Function))
|
||||
expect(context.drawers.swipeDown).toEqual(expect.any(Function))
|
||||
expect(context.gestures.swipe).toEqual(expect.any(Function))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { StageWindowName, StageWindowSnapshot } from './runtime/windows'
|
||||
|
||||
import { defineScenario } from '@vishot/source-electron'
|
||||
|
||||
import { swipe } from './runtime/gestures'
|
||||
import { dismissDialog, dismissDrawer, swipeDownDrawer } from './runtime/overlays'
|
||||
import { expandControlsIsland, openChatFromControlsIsland, openHearingFromControlsIsland, openSettingsFromControlsIsland, waitForControlsIslandReady } from './runtime/selectors'
|
||||
import { goToSettingsConnectionPage, goToSettingsRoute } from './runtime/settings'
|
||||
@@ -37,6 +38,10 @@ export interface DrawersApi {
|
||||
dismiss: (page: Page) => Promise<void>
|
||||
}
|
||||
|
||||
export interface GesturesApi {
|
||||
swipe: typeof swipe
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic Vishot Electron context plus AIRI stage-tamagotchi navigation helpers.
|
||||
*/
|
||||
@@ -46,6 +51,7 @@ export interface StageTamagotchiScenarioContext extends ScenarioContext {
|
||||
settingsWindow: SettingsWindowApi
|
||||
dialogs: DialogsApi
|
||||
drawers: DrawersApi
|
||||
gestures: GesturesApi
|
||||
}
|
||||
|
||||
export interface StageTamagotchiScenario {
|
||||
@@ -107,6 +113,9 @@ export function createStageTamagotchiScenarioContext(context: ScenarioContext):
|
||||
return dismissDrawer(page)
|
||||
},
|
||||
},
|
||||
gestures: {
|
||||
swipe,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export type { SwipeGestureDirection, SwipeGestureInput, SwipeGestureOptions } from './runtime/gestures'
|
||||
export { swipe } from './runtime/gestures'
|
||||
export { default as demoControlsSettingsChatWebsocketScenario } from './scenarios/demo-controls-settings-chat-websocket/index'
|
||||
export { default as demoDismissSurfacesScenario } from './scenarios/demo-dismiss-surfaces'
|
||||
export { default as demoHearingDialogScenario } from './scenarios/demo-hearing-dialog'
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { BrowserContext, CDPSession, Locator, Page } from 'playwright'
|
||||
|
||||
import { chromium } from 'playwright'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { swipe } from './gestures'
|
||||
|
||||
function createGestureTarget() {
|
||||
const send = vi.fn().mockResolvedValue({})
|
||||
const detach = vi.fn().mockResolvedValue(undefined)
|
||||
const cdpSession = {
|
||||
detach,
|
||||
send,
|
||||
} as unknown as CDPSession
|
||||
const newCDPSession = vi.fn().mockResolvedValue(cdpSession)
|
||||
const context = {
|
||||
newCDPSession,
|
||||
} as unknown as BrowserContext
|
||||
const mouse = {
|
||||
move: vi.fn().mockResolvedValue(undefined),
|
||||
wheel: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
const page = {
|
||||
context: () => context,
|
||||
mouse,
|
||||
waitForTimeout: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as Page
|
||||
const target = {
|
||||
boundingBox: vi.fn().mockResolvedValue({ x: 20, y: 40, width: 200, height: 80 }),
|
||||
page: () => page,
|
||||
} as unknown as Locator
|
||||
|
||||
return { detach, mouse, newCDPSession, page, send, target }
|
||||
}
|
||||
|
||||
describe('swipe', () => {
|
||||
// https://github.com/moeru-ai/airi/pull/2489#discussion_r3967436796
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Locator.dispatchEvent creates untrusted Pointer Events. Swipeable ignores
|
||||
// those moves for pointer capture, so the scenario skipped native retargeting.
|
||||
//
|
||||
// The helper now sends a trusted touch stream through Chromium CDP.
|
||||
it('sends a trusted touch sequence through Chromium CDP', async () => {
|
||||
const { detach, newCDPSession, page, send, target } = createGestureTarget()
|
||||
|
||||
await swipe(target, {
|
||||
direction: 'left',
|
||||
distance: 64,
|
||||
input: 'touch',
|
||||
steps: 2,
|
||||
})
|
||||
|
||||
expect(newCDPSession).toHaveBeenCalledWith(page)
|
||||
expect(send).toHaveBeenNthCalledWith(1, 'Input.dispatchTouchEvent', {
|
||||
touchPoints: [{ id: 1, x: 120, y: 80 }],
|
||||
type: 'touchStart',
|
||||
})
|
||||
expect(send).toHaveBeenNthCalledWith(2, 'Input.dispatchTouchEvent', {
|
||||
touchPoints: [{ id: 1, x: 88, y: 80 }],
|
||||
type: 'touchMove',
|
||||
})
|
||||
expect(send).toHaveBeenNthCalledWith(3, 'Input.dispatchTouchEvent', {
|
||||
touchPoints: [{ id: 1, x: 56, y: 80 }],
|
||||
type: 'touchMove',
|
||||
})
|
||||
expect(send).toHaveBeenNthCalledWith(4, 'Input.dispatchTouchEvent', {
|
||||
touchPoints: [],
|
||||
type: 'touchEnd',
|
||||
})
|
||||
expect(detach).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cancels an active touch before detaching after an input failure', async () => {
|
||||
const { detach, send, target } = createGestureTarget()
|
||||
send
|
||||
.mockResolvedValueOnce({})
|
||||
.mockRejectedValueOnce(new Error('Touch move failed.'))
|
||||
.mockResolvedValueOnce({})
|
||||
|
||||
await expect(swipe(target, {
|
||||
direction: 'left',
|
||||
input: 'touch',
|
||||
steps: 2,
|
||||
})).rejects.toThrow('Touch move failed.')
|
||||
|
||||
expect(send).toHaveBeenLastCalledWith('Input.dispatchTouchEvent', {
|
||||
touchPoints: [],
|
||||
type: 'touchCancel',
|
||||
})
|
||||
expect(detach).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('produces trusted pointer capture and native cancellation in Chromium', async () => {
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
|
||||
try {
|
||||
const page = await browser.newPage()
|
||||
await page.setContent(`
|
||||
<button id="target" style="position: absolute; left: 250px; top: 20px; width: 100px; height: 80px; touch-action: pan-y">
|
||||
Target
|
||||
</button>
|
||||
`)
|
||||
await page.evaluate(() => {
|
||||
const target = document.querySelector('#target')
|
||||
if (!(target instanceof HTMLElement))
|
||||
throw new Error('Expected a gesture target.')
|
||||
|
||||
const events: Array<{ captured: boolean, targetId: string, trusted: boolean, type: string }> = []
|
||||
Reflect.set(globalThis, 'observedPointerEvents', events)
|
||||
let captureRequested = false
|
||||
|
||||
for (const type of ['gotpointercapture', 'pointercancel', 'pointerdown', 'pointermove', 'pointerup']) {
|
||||
target.addEventListener(type, (rawEvent) => {
|
||||
const event = rawEvent as PointerEvent
|
||||
if (event.type === 'pointermove' && event.isTrusted && !captureRequested) {
|
||||
captureRequested = true
|
||||
target.setPointerCapture(event.pointerId)
|
||||
}
|
||||
events.push({
|
||||
captured: target.hasPointerCapture(event.pointerId),
|
||||
targetId: event.target instanceof HTMLElement ? event.target.id : '',
|
||||
trusted: event.isTrusted,
|
||||
type: event.type,
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
await swipe(page.locator('#target'), {
|
||||
direction: 'left',
|
||||
distance: 200,
|
||||
input: 'touch',
|
||||
steps: 2,
|
||||
})
|
||||
|
||||
const capturedEvents = await page.evaluate(() => Reflect.get(globalThis, 'observedPointerEvents')) as Array<{
|
||||
captured: boolean
|
||||
targetId: string
|
||||
trusted: boolean
|
||||
type: string
|
||||
}>
|
||||
expect(capturedEvents.map(event => event.type)).toEqual([
|
||||
'pointerdown',
|
||||
'gotpointercapture',
|
||||
'pointermove',
|
||||
'pointermove',
|
||||
'pointerup',
|
||||
])
|
||||
expect(capturedEvents.every(event => event.trusted)).toBe(true)
|
||||
expect(capturedEvents
|
||||
.filter(event => event.type === 'pointermove' || event.type === 'pointerup')
|
||||
.every(event => event.captured && event.targetId === 'target'))
|
||||
.toBe(true)
|
||||
|
||||
await page.setContent(`
|
||||
<div style="width: 2000px">
|
||||
<button id="target" style="width: 100px; height: 80px; touch-action: auto">Target</button>
|
||||
</div>
|
||||
`)
|
||||
await page.evaluate(() => {
|
||||
const target = document.querySelector('#target')
|
||||
if (!(target instanceof HTMLElement))
|
||||
throw new Error('Expected a gesture target.')
|
||||
|
||||
const events: Array<{ trusted: boolean, type: string }> = []
|
||||
Reflect.set(globalThis, 'observedPointerEvents', events)
|
||||
target.addEventListener('pointercancel', (event) => {
|
||||
events.push({ trusted: event.isTrusted, type: event.type })
|
||||
})
|
||||
})
|
||||
|
||||
await swipe(page.locator('#target'), {
|
||||
direction: 'left',
|
||||
distance: 64,
|
||||
input: 'touch',
|
||||
steps: 2,
|
||||
})
|
||||
|
||||
expect(await page.evaluate(() => Reflect.get(globalThis, 'observedPointerEvents'))).toEqual([
|
||||
{ trusted: true, type: 'pointercancel' },
|
||||
])
|
||||
}
|
||||
finally {
|
||||
await browser.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('sends a left two-finger pan as positive horizontal wheel movement', async () => {
|
||||
const { mouse, page, target } = createGestureTarget()
|
||||
|
||||
await swipe(target, {
|
||||
direction: 'left',
|
||||
distance: 64,
|
||||
input: 'wheel',
|
||||
steps: 4,
|
||||
})
|
||||
|
||||
expect(mouse.move).toHaveBeenCalledWith(120, 80)
|
||||
expect(mouse.wheel).toHaveBeenCalledTimes(4)
|
||||
for (const call of mouse.wheel.mock.calls)
|
||||
expect(call).toEqual([16, 0])
|
||||
expect(page.waitForTimeout).toHaveBeenCalledWith(16)
|
||||
})
|
||||
|
||||
it('rejects a gesture when the target is not visible', async () => {
|
||||
const { target } = createGestureTarget()
|
||||
vi.mocked(target.boundingBox).mockResolvedValue(null)
|
||||
|
||||
await expect(swipe(target, {
|
||||
direction: 'left',
|
||||
input: 'touch',
|
||||
})).rejects.toThrow('Cannot swipe an element without a visible bounding box.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { Locator, Page } from 'playwright'
|
||||
|
||||
/** Input source used to reproduce a swipe in an Electron scenario. */
|
||||
export type SwipeGestureInput = 'touch' | 'wheel'
|
||||
|
||||
/** Physical direction in which the user moves their fingers. */
|
||||
export type SwipeGestureDirection = 'down' | 'left' | 'right' | 'up'
|
||||
|
||||
/** Configuration for one element-centered swipe gesture. */
|
||||
export interface SwipeGestureOptions {
|
||||
/** Selects CDP touch input or two-finger wheel movement. */
|
||||
input: SwipeGestureInput
|
||||
/** Selects the physical direction of finger travel. */
|
||||
direction: SwipeGestureDirection
|
||||
/** Sets the total raw travel in pixels. @default 64 */
|
||||
distance?: number
|
||||
/** Sets the number of input samples. @default 8 */
|
||||
steps?: number
|
||||
}
|
||||
|
||||
const gestureFrameIntervalMs = 16
|
||||
const gestureSettleTimeMs = 350
|
||||
|
||||
/**
|
||||
* Performs one swipe through the input path used by the AIRI gesture modules.
|
||||
*
|
||||
* Touch input uses Chromium CDP so the browser produces trusted Pointer Events.
|
||||
* Wheel input uses Playwright's native mouse wheel path.
|
||||
*/
|
||||
export async function swipe(target: Locator, options: SwipeGestureOptions): Promise<void> {
|
||||
const box = await target.boundingBox()
|
||||
if (!box)
|
||||
throw new Error('Cannot swipe an element without a visible bounding box.')
|
||||
|
||||
const distance = options.distance ?? 64
|
||||
if (distance <= 0)
|
||||
throw new Error('Swipe distance must be greater than zero.')
|
||||
|
||||
const steps = Math.trunc(options.steps ?? 8)
|
||||
if (steps <= 0)
|
||||
throw new Error('Swipe steps must be greater than zero.')
|
||||
|
||||
const page = target.page()
|
||||
const start = {
|
||||
x: box.x + box.width / 2,
|
||||
y: box.y + box.height / 2,
|
||||
}
|
||||
const pointerVector = pointerDirectionVector(options.direction)
|
||||
|
||||
if (options.input === 'touch') {
|
||||
await dispatchTouchSwipe(page, start, pointerVector, distance, steps)
|
||||
await page.waitForTimeout(gestureSettleTimeMs)
|
||||
return
|
||||
}
|
||||
|
||||
const wheelVector = wheelDirectionVector(options.direction)
|
||||
await page.mouse.move(start.x, start.y)
|
||||
for (let step = 0; step < steps; step += 1) {
|
||||
await page.mouse.wheel(
|
||||
wheelVector.x * distance / steps,
|
||||
wheelVector.y * distance / steps,
|
||||
)
|
||||
await page.waitForTimeout(gestureFrameIntervalMs)
|
||||
}
|
||||
await page.waitForTimeout(gestureSettleTimeMs)
|
||||
}
|
||||
|
||||
async function dispatchTouchSwipe(
|
||||
page: Page,
|
||||
start: { x: number, y: number },
|
||||
pointerVector: { x: number, y: number },
|
||||
distance: number,
|
||||
steps: number,
|
||||
) {
|
||||
const cdpSession = await page.context().newCDPSession(page)
|
||||
let touchActive = false
|
||||
|
||||
try {
|
||||
await cdpSession.send('Input.dispatchTouchEvent', {
|
||||
touchPoints: [{ id: 1, x: start.x, y: start.y }],
|
||||
type: 'touchStart',
|
||||
})
|
||||
touchActive = true
|
||||
|
||||
for (let step = 1; step <= steps; step += 1) {
|
||||
const progress = step / steps
|
||||
await cdpSession.send('Input.dispatchTouchEvent', {
|
||||
touchPoints: [{
|
||||
id: 1,
|
||||
x: start.x + pointerVector.x * distance * progress,
|
||||
y: start.y + pointerVector.y * distance * progress,
|
||||
}],
|
||||
type: 'touchMove',
|
||||
})
|
||||
await page.waitForTimeout(gestureFrameIntervalMs)
|
||||
}
|
||||
|
||||
await cdpSession.send('Input.dispatchTouchEvent', {
|
||||
touchPoints: [],
|
||||
type: 'touchEnd',
|
||||
})
|
||||
touchActive = false
|
||||
}
|
||||
finally {
|
||||
try {
|
||||
if (touchActive) {
|
||||
await cdpSession.send('Input.dispatchTouchEvent', {
|
||||
touchPoints: [],
|
||||
type: 'touchCancel',
|
||||
})
|
||||
}
|
||||
}
|
||||
finally {
|
||||
await cdpSession.detach()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pointerDirectionVector(direction: SwipeGestureDirection) {
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
return { x: -1, y: 0 }
|
||||
case 'right':
|
||||
return { x: 1, y: 0 }
|
||||
case 'up':
|
||||
return { x: 0, y: -1 }
|
||||
case 'down':
|
||||
return { x: 0, y: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
function wheelDirectionVector(direction: SwipeGestureDirection) {
|
||||
switch (direction) {
|
||||
// Browser wheel deltas describe content travel, opposite to finger travel.
|
||||
case 'left':
|
||||
return { x: 1, y: 0 }
|
||||
case 'right':
|
||||
return { x: -1, y: 0 }
|
||||
case 'up':
|
||||
return { x: 0, y: 1 }
|
||||
case 'down':
|
||||
return { x: 0, y: -1 }
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
|
||||
|
||||
import { ChatHistory } from '@proj-airi/stage-ui/components'
|
||||
import { useChatComposer } from '@proj-airi/stage-ui/components/scenarios/chat'
|
||||
import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
|
||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||
@@ -18,12 +19,22 @@ import ChatContainer from '../Widgets/ChatContainer.vue'
|
||||
import { useChatToolCallRerun } from '../../composables/useChatToolCallRerun'
|
||||
|
||||
const { isReady } = useDeferredMount()
|
||||
const { activeSendSessionId, activeStreamingMessage, sending } = storeToRefs(useChatStore())
|
||||
const chatOrchestrator = useChatStore()
|
||||
const { activeSendSessionId, activeStreamingMessage, sending } = storeToRefs(chatOrchestrator)
|
||||
const { activeSessionId, messages } = storeToRefs(useChatSessionStore())
|
||||
const { streamingMessage } = storeToRefs(useChatStreamStore())
|
||||
const { isReceivingRemoteStream } = storeToRefs(useContextBridgeStore())
|
||||
|
||||
const isLoading = ref(true)
|
||||
const composer = useChatComposer({
|
||||
activeSessionId,
|
||||
send: submission => chatOrchestrator.send({
|
||||
sessionId: submission.sessionId,
|
||||
text: submission.text,
|
||||
replyToMessageId: submission.replyToMessageId,
|
||||
}),
|
||||
})
|
||||
const { clearReplyForMessage, selectReply } = composer
|
||||
const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[])
|
||||
const isActiveSessionSending = computed(() => (
|
||||
(sending.value && activeSendSessionId.value === activeSessionId.value)
|
||||
@@ -35,8 +46,8 @@ const visibleStreamingMessage = computed(() => activeSendSessionId.value === act
|
||||
const { trackChatMessageDeleted } = useAnalytics()
|
||||
const { rerunToolCall } = useChatToolCallRerun()
|
||||
|
||||
async function handleDeleteMessage(index: number) {
|
||||
const message = messages.value[index]
|
||||
async function handleDeleteMessage(payload: { message: ChatHistoryItem, index: number }) {
|
||||
const { index, message } = payload
|
||||
await useChatSessionStore().deleteMessage({
|
||||
sessionId: activeSessionId.value,
|
||||
messageId: message?.id,
|
||||
@@ -46,6 +57,7 @@ async function handleDeleteMessage(index: number) {
|
||||
source: 'history',
|
||||
message_role: message?.role ?? 'unknown',
|
||||
})
|
||||
clearReplyForMessage(message)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -68,12 +80,13 @@ async function handleDeleteMessage(index: number) {
|
||||
:streaming-message="visibleStreamingMessage"
|
||||
h-full
|
||||
variant="desktop"
|
||||
@delete-message="handleDeleteMessage($event.index)"
|
||||
@delete-message="handleDeleteMessage"
|
||||
@reply-message="selectReply"
|
||||
@tool-call-rerun="rerunToolCall"
|
||||
@vue:mounted="isLoading = false"
|
||||
/>
|
||||
</div>
|
||||
<ChatArea />
|
||||
<ChatArea :composer="composer" />
|
||||
</ChatContainer>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChatHistoryReplyPayload } from '@proj-airi/stage-ui/components/scenarios/chat'
|
||||
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 { CharacterSwitcherDrawer, ChatHistory } from '@proj-airi/stage-ui/components'
|
||||
import { ChatSessionsDrawer } from '@proj-airi/stage-ui/components/scenarios/chat'
|
||||
import { ChatReplyPreview, ChatSessionsDrawer, useChatComposer } 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'
|
||||
@@ -53,9 +53,24 @@ const visibleStreamingMessage = computed(() => activeSendSessionId.value === act
|
||||
: streamingMessage.value)
|
||||
const { trackChatMessageDeleted } = useAnalytics()
|
||||
const { rerunToolCall } = useChatToolCallRerun()
|
||||
const composer = useChatComposer({
|
||||
activeSessionId,
|
||||
send: submission => chatOrchestrator.send({
|
||||
sessionId: submission.sessionId,
|
||||
text: submission.text,
|
||||
replyToMessageId: submission.replyToMessageId,
|
||||
}),
|
||||
})
|
||||
const {
|
||||
clearReplyForMessage,
|
||||
draft: messageInput,
|
||||
isComposing,
|
||||
replyTarget,
|
||||
selectReply,
|
||||
} = composer
|
||||
|
||||
async function handleDeleteMessage(index: number) {
|
||||
const message = messages.value[index]
|
||||
async function handleDeleteMessage(payload: { message: ChatHistoryItem, index: number }) {
|
||||
const { index, message } = payload
|
||||
await chatSession.deleteMessage({
|
||||
sessionId: activeSessionId.value,
|
||||
messageId: message?.id,
|
||||
@@ -65,10 +80,9 @@ async function handleDeleteMessage(index: number) {
|
||||
source: 'history',
|
||||
message_role: message?.role ?? 'unknown',
|
||||
})
|
||||
clearReplyForMessage(message)
|
||||
}
|
||||
|
||||
const messageInput = shallowRef('')
|
||||
const isComposing = shallowRef(false)
|
||||
const inputBubbleDocked = shallowRef(false)
|
||||
const inputBubbleDragging = shallowRef(false)
|
||||
const inputBubbleAnimating = shallowRef(false)
|
||||
@@ -367,6 +381,21 @@ async function handleInputBubbleClick() {
|
||||
inputBubble.value!.querySelector<HTMLTextAreaElement>('textarea')!.focus()
|
||||
}
|
||||
|
||||
async function handleReplyMessage(payload: ChatHistoryReplyPayload) {
|
||||
if (inputBubbleDocked.value)
|
||||
await setInputBubbleDocked(false)
|
||||
|
||||
selectReply(payload)
|
||||
await nextTick()
|
||||
inputBubble.value?.querySelector<HTMLTextAreaElement>('textarea')?.focus()
|
||||
}
|
||||
|
||||
async function handleCancelReply() {
|
||||
composer.clearReply()
|
||||
await nextTick()
|
||||
inputBubble.value?.querySelector<HTMLTextAreaElement>('textarea')?.focus()
|
||||
}
|
||||
|
||||
async function handleInputBubblePointerCancel() {
|
||||
inputBubbleDragging.value = false
|
||||
await resetInputBubblePosition()
|
||||
@@ -385,30 +414,7 @@ async function handleSubmit() {
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
if (!messageInput.value.trim() || isComposing.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const textToSend = messageInput.value
|
||||
const targetSessionId = chatSession.activeSessionId
|
||||
messageInput.value = ''
|
||||
|
||||
try {
|
||||
await chatOrchestrator.send({
|
||||
sessionId: targetSessionId,
|
||||
text: textToSend,
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = errorMessageFrom(error) ?? String(error)
|
||||
const wasCancelledForDeletedSession
|
||||
= errorMessage.includes('Chat session was reset before send could start')
|
||||
|| errorMessage.includes('Chat session was removed before send completed')
|
||||
if (!wasCancelledForDeletedSession && chatSession.activeSessionId === targetSessionId) {
|
||||
const currentDraft = messageInput.value
|
||||
messageInput.value = currentDraft ? `${textToSend}\n${currentDraft}` : textToSend
|
||||
}
|
||||
}
|
||||
await composer.submit()
|
||||
}
|
||||
|
||||
function teardownAnalyzer() {
|
||||
@@ -506,7 +512,8 @@ onUnmounted(() => {
|
||||
class="chat-history"
|
||||
:style="chatHistoryStyle"
|
||||
:class="chatHistoryClass"
|
||||
@delete-message="handleDeleteMessage($event.index)"
|
||||
@delete-message="handleDeleteMessage"
|
||||
@reply-message="handleReplyMessage"
|
||||
@tool-call-rerun="rerunToolCall"
|
||||
/>
|
||||
</Transition>
|
||||
@@ -559,22 +566,29 @@ onUnmounted(() => {
|
||||
data-testid="mobile-input-bubble"
|
||||
:data-dragging="inputBubbleDragging"
|
||||
:class="[
|
||||
'group relative mx-auto min-h-10 flex items-end origin-center',
|
||||
'group relative mx-auto min-h-10 flex flex-col justify-end origin-center overflow-hidden',
|
||||
'touch-none select-none focus-within:touch-auto focus-within:select-text',
|
||||
'border-2 border-solid border-neutral-200/60 bg-neutral-100/80 backdrop-blur-md',
|
||||
'dark:border-neutral-700/60 dark:bg-neutral-950/80',
|
||||
inputBubbleDragging || inputBubbleAnimating
|
||||
? 'transition-none'
|
||||
: 'transition-[max-width] duration-320 [transition-timing-function:cubic-bezier(0.16,1,0.3,1)]',
|
||||
inputBubbleDocked
|
||||
? [
|
||||
'h-10 max-w-10 w-10 cursor-pointer rounded-xl border-2 border-solid backdrop-blur-md',
|
||||
'h-10 max-w-10 w-10 cursor-pointer rounded-xl',
|
||||
'border-neutral-100/60 bg-neutral-50/70 dark:border-neutral-800/30 dark:bg-neutral-800/70',
|
||||
]
|
||||
: 'max-w-[70%] w-full focus-within:max-w-full',
|
||||
: 'max-w-[70%] w-full rounded-[1lh] focus-within:max-w-full',
|
||||
]"
|
||||
@click="handleInputBubbleClick"
|
||||
@contextmenu="handleInputBubbleContextMenu"
|
||||
@pointerdown="handleInputBubblePointerDown"
|
||||
>
|
||||
<ChatReplyPreview
|
||||
:target="replyTarget"
|
||||
:class="['w-full']"
|
||||
@cancel="handleCancelReply"
|
||||
/>
|
||||
<!-- Android handles touch from the scrollable textarea, so it needs touch-none to keep the bubble drag active. -->
|
||||
<BasicTextarea
|
||||
v-model="messageInput"
|
||||
@@ -586,9 +600,8 @@ onUnmounted(() => {
|
||||
:class="[
|
||||
'font-cute',
|
||||
'max-h-[10lh] min-h-[calc(1lh+4px+4px)] w-full touch-none resize-none overflow-y-scroll scrollbar-none',
|
||||
'border-2 border-solid px-4 py-0.5 outline-none backdrop-blur-md',
|
||||
'border-2 border-solid border-transparent bg-transparent px-4 py-0.5 outline-none',
|
||||
'text-neutral-500 dark:text-neutral-100',
|
||||
'rounded-[1lh] border-neutral-200/60 bg-neutral-100/80 dark:border-neutral-700/60 dark:bg-neutral-950/80',
|
||||
'transition-colors duration-250 ease-in-out hover:text-neutral-600 dark:hover:text-neutral-200',
|
||||
'placeholder:text-[14px] placeholder:vertical-middle placeholder:leading-6 placeholder:text-neutral-400',
|
||||
'placeholder:transition-all placeholder:duration-250 placeholder:ease-in-out placeholder:hover:text-neutral-500 dark:placeholder:text-neutral-500 dark:placeholder:hover:text-neutral-400',
|
||||
@@ -624,6 +637,7 @@ onUnmounted(() => {
|
||||
</button>
|
||||
<button
|
||||
v-if="messageInput.trim() || isComposing"
|
||||
:aria-label="t('stage.chat.actions.send')"
|
||||
w="[calc(1lh+4px+4px)]" h="[calc(1lh+4px+4px)]" aspect-square flex items-center self-end justify-center rounded-full outline-none backdrop-blur-md
|
||||
text="neutral-500 hover:neutral-600 dark:neutral-900 dark:hover:neutral-800"
|
||||
bg="primary-50/80 dark:neutral-100/80 hover:neutral-50"
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import type { ChatComposerController } from '@proj-airi/stage-ui/components/scenarios/chat'
|
||||
|
||||
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
import { ChatReplyPreview } from '@proj-airi/stage-ui/components/scenarios/chat'
|
||||
import { HearingConfig } from '@proj-airi/stage-ui/components/scenarios/dialogs/audio-input/index'
|
||||
import { 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 { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { BasicTextarea } from '@proj-airi/ui'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuTrigger, PopoverContent, PopoverRoot, PopoverTrigger } from 'reka-ui'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IndicatorMicVolume from './IndicatorMicVolume.vue'
|
||||
@@ -19,9 +19,15 @@ import IndicatorMicVolume from './IndicatorMicVolume.vue'
|
||||
import { useTranscriptions } from '../../composables/use-transcriptions'
|
||||
import { useStopSpeakingButton } from '../../composables/useStopSpeakingButton'
|
||||
|
||||
const messageInput = ref<string>('')
|
||||
const props = defineProps<{
|
||||
composer: ChatComposerController<never>
|
||||
}>()
|
||||
|
||||
const composerRoot = useTemplateRef<HTMLDivElement>('composer')
|
||||
|
||||
const messageInput = props.composer.draft
|
||||
const hearingPopoverOpen = ref(false)
|
||||
const isComposing = ref(false)
|
||||
const isComposing = props.composer.isComposing
|
||||
const DOUBLE_ENTER_INTERVAL_MS = 300
|
||||
const TRAILING_NEWLINES_REGEX = /[\r\n]+$/
|
||||
const SEND_MODES = ['enter', 'ctrl-enter', 'double-enter'] as const
|
||||
@@ -33,8 +39,7 @@ const { themeColorsHueDynamic } = storeToRefs(useSettings())
|
||||
|
||||
const { askPermission } = useSettingsAudioDevice()
|
||||
const { enabled, stream } = storeToRefs(useSettingsAudioDevice())
|
||||
const chatOrchestrator = useChatStore()
|
||||
const chatSession = useChatSessionStore()
|
||||
const replyTarget = props.composer.replyTarget
|
||||
const { audioContext } = useAudioContext()
|
||||
const { t } = useI18n()
|
||||
const sendModeLabels = computed<Record<SendMode, string>>(() => ({
|
||||
@@ -53,30 +58,13 @@ const { isListening, startStreamingTranscription, stopStreamingTranscription, au
|
||||
const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton()
|
||||
|
||||
async function handleSend() {
|
||||
if (!messageInput.value.trim() || isComposing.value) {
|
||||
return
|
||||
}
|
||||
await props.composer.submit()
|
||||
}
|
||||
|
||||
const textToSend = messageInput.value
|
||||
const targetSessionId = chatSession.activeSessionId
|
||||
messageInput.value = ''
|
||||
|
||||
try {
|
||||
await chatOrchestrator.send({
|
||||
sessionId: targetSessionId,
|
||||
text: textToSend,
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = errorMessageFrom(error) ?? String(error)
|
||||
const wasCancelledForDeletedSession
|
||||
= errorMessage.includes('Chat session was reset before send could start')
|
||||
|| errorMessage.includes('Chat session was removed before send completed')
|
||||
if (!wasCancelledForDeletedSession && chatSession.activeSessionId === targetSessionId) {
|
||||
const currentDraft = messageInput.value
|
||||
messageInput.value = currentDraft ? `${textToSend}\n${currentDraft}` : textToSend
|
||||
}
|
||||
}
|
||||
async function handleCancelReply() {
|
||||
props.composer.clearReply()
|
||||
await nextTick()
|
||||
composerRoot.value?.querySelector('textarea')?.focus()
|
||||
}
|
||||
|
||||
function sendFromKeyboard() {
|
||||
@@ -161,17 +149,29 @@ onUnmounted(() => {
|
||||
watch(sendMode, () => {
|
||||
lastEnterTime.value = 0
|
||||
})
|
||||
|
||||
watch(replyTarget, async (target) => {
|
||||
if (!target)
|
||||
return
|
||||
|
||||
await nextTick()
|
||||
composerRoot.value?.querySelector('textarea')?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div h="<md:full" flex gap-2 class="ph-no-capture">
|
||||
<div ref="composer" h="<md:full" flex gap-2 class="ph-no-capture">
|
||||
<div
|
||||
:class="[
|
||||
'relative',
|
||||
'w-full',
|
||||
'bg-primary-200/20 dark:bg-primary-400/20',
|
||||
'relative w-full overflow-hidden rounded-t-xl',
|
||||
'border-t-2 border-solid border-primary-200/20 bg-primary-100/50 backdrop-blur-md',
|
||||
'dark:border-primary-400/20 dark:bg-primary-900/70',
|
||||
]"
|
||||
>
|
||||
<ChatReplyPreview
|
||||
:target="replyTarget"
|
||||
@cancel="handleCancelReply"
|
||||
/>
|
||||
<BasicTextarea
|
||||
v-model="messageInput"
|
||||
:submit-on-enter="false"
|
||||
@@ -179,7 +179,7 @@ watch(sendMode, () => {
|
||||
text="primary-600 dark:primary-100 placeholder:primary-500 dark:placeholder:primary-200"
|
||||
bg="transparent"
|
||||
min-h="[100px]" max-h="[300px]" w-full
|
||||
rounded-t-xl p-4 font-medium pb="[60px]"
|
||||
p-4 font-medium pb="[60px]"
|
||||
outline-none transition="all duration-250 ease-in-out placeholder:all placeholder:duration-250 placeholder:ease-in-out"
|
||||
:class="{
|
||||
'transition-colors-none placeholder:transition-colors-none': themeColorsHueDynamic,
|
||||
|
||||
@@ -173,6 +173,7 @@
|
||||
"vue-router": "catalog:",
|
||||
"vue-sonner": "catalog:",
|
||||
"web-haptics": "catalog:",
|
||||
"wheel-gestures": "catalog:",
|
||||
"xast-util-to-xml": "catalog:",
|
||||
"xastscript": "catalog:",
|
||||
"xsschema": "catalog:",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export type { SwipeableDirection, SwipeableInput, SwipeableProps, SwipeableSlotProps } from './swipeable'
|
||||
export { default as Swipeable } from './swipeable.vue'
|
||||
export type { UseSwipeGestureOptions, UseSwipeGestureReturn } from './use-swipe-gesture'
|
||||
export { useSwipeGesture } from './use-swipe-gesture'
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Input source that can drive Swipeable. */
|
||||
export type SwipeableInput = 'pointer' | 'wheel'
|
||||
|
||||
/** Horizontal direction that selects the action. */
|
||||
export type SwipeableDirection = 'left' | 'right'
|
||||
|
||||
/** Configuration for the Swipeable gesture primitive. */
|
||||
export interface SwipeableProps {
|
||||
/** Enables gesture recognition. @default true */
|
||||
enabled?: boolean
|
||||
/** Selects touch/pointer dragging or desktop horizontal-wheel input. @default 'pointer' */
|
||||
input?: SwipeableInput
|
||||
/** Selects the horizontal direction that commits the action. @default 'left' */
|
||||
direction?: SwipeableDirection
|
||||
/** Ignores pointer or wheel jitter below this distance, in pixels. @default 8 */
|
||||
startDistance?: number
|
||||
/** Commits the action when the directed distance reaches this value, in pixels. @default 48 */
|
||||
threshold?: number
|
||||
}
|
||||
|
||||
/** Reactive state exposed to the Swipeable default slot. */
|
||||
export interface SwipeableSlotProps {
|
||||
/** True after the gesture has locked to the configured direction. */
|
||||
active: boolean
|
||||
/** Signed horizontal offset for the swipeable content, in pixels. */
|
||||
offset: number
|
||||
/** Progress from rest to the commit threshold, clamped from 0 to 1. */
|
||||
progress: number
|
||||
/** True while the current gesture meets the commit threshold. */
|
||||
thresholdCrossed: boolean
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
<script setup lang="ts">
|
||||
import type { SwipeableProps, SwipeableSlotProps } from './swipeable'
|
||||
|
||||
import { useEventListener, usePreferredReducedMotion } from '@vueuse/core'
|
||||
import { animate } from 'animejs'
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { computed, onUnmounted, reactive, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { useSwipeGesture } from './use-swipe-gesture'
|
||||
|
||||
const props = withDefaults(defineProps<SwipeableProps>(), {
|
||||
direction: 'left',
|
||||
enabled: true,
|
||||
input: 'pointer',
|
||||
startDistance: 8,
|
||||
threshold: 48,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
commit: []
|
||||
thresholdEnter: []
|
||||
}>()
|
||||
|
||||
defineSlots<{
|
||||
default: (props: SwipeableSlotProps) => unknown
|
||||
}>()
|
||||
|
||||
const rootRef = useTemplateRef<HTMLDivElement>('root')
|
||||
const { state: wheelGesture } = useSwipeGesture(rootRef, {
|
||||
filter: event => props.enabled
|
||||
&& props.input === 'wheel'
|
||||
&& !event.ctrlKey
|
||||
&& event.deltaMode === WheelEvent.DOM_DELTA_PIXEL,
|
||||
})
|
||||
const position = reactive({ x: 0 })
|
||||
const active = shallowRef(false)
|
||||
const thresholdCrossed = shallowRef(false)
|
||||
const reducedMotion = usePreferredReducedMotion()
|
||||
const progress = computed(() => clamp(Math.abs(position.x) / props.threshold, 0, 1))
|
||||
const slotProps = computed<SwipeableSlotProps>(() => ({
|
||||
active: active.value,
|
||||
offset: position.x,
|
||||
progress: progress.value,
|
||||
thresholdCrossed: thresholdCrossed.value,
|
||||
}))
|
||||
|
||||
let returnAnimation: ReturnType<typeof animate> | undefined
|
||||
let activePointerId: number | undefined
|
||||
let pointerStartX = 0
|
||||
let pointerStartY = 0
|
||||
let wheelDistance = 0
|
||||
let wheelIntent: 'pending' | 'horizontal' | 'vertical' = 'pending'
|
||||
let wheelSessionActive = false
|
||||
let positionFrame: number | undefined
|
||||
let pendingPositionX = 0
|
||||
|
||||
function directedDistance(deltaX: number) {
|
||||
return props.direction === 'left' ? deltaX : -deltaX
|
||||
}
|
||||
|
||||
function mapGestureDistance(distance: number) {
|
||||
const rootWidth = rootRef.value?.clientWidth ?? 0
|
||||
if (rootWidth <= 0)
|
||||
return distance
|
||||
|
||||
const resistanceLength = rootWidth / 4
|
||||
|
||||
// The curve starts with a 1:1 slope, then increases resistance continuously.
|
||||
// Its visual distance approaches a quarter row width without reaching a hard stop.
|
||||
return resistanceLength * -Math.expm1(-distance / resistanceLength)
|
||||
}
|
||||
|
||||
function setGestureDistance(distance: number) {
|
||||
const positiveDistance = Math.max(0, distance)
|
||||
const visibleDistance = mapGestureDistance(positiveDistance)
|
||||
const direction = props.direction === 'left' ? -1 : 1
|
||||
pendingPositionX = direction * visibleDistance
|
||||
|
||||
const crossed = positiveDistance >= props.threshold
|
||||
if (crossed && !thresholdCrossed.value)
|
||||
emit('thresholdEnter')
|
||||
thresholdCrossed.value = crossed
|
||||
|
||||
if (positionFrame !== undefined)
|
||||
return
|
||||
|
||||
positionFrame = requestAnimationFrame(() => {
|
||||
position.x = pendingPositionX
|
||||
positionFrame = undefined
|
||||
})
|
||||
}
|
||||
|
||||
function animatePositionToRest() {
|
||||
returnAnimation?.cancel()
|
||||
if (positionFrame !== undefined) {
|
||||
cancelAnimationFrame(positionFrame)
|
||||
positionFrame = undefined
|
||||
}
|
||||
pendingPositionX = 0
|
||||
|
||||
if (reducedMotion.value === 'reduce') {
|
||||
position.x = 0
|
||||
return
|
||||
}
|
||||
|
||||
returnAnimation = animate(position, {
|
||||
x: 0,
|
||||
duration: 220,
|
||||
ease: 'outQuart',
|
||||
})
|
||||
}
|
||||
|
||||
function resetPosition() {
|
||||
active.value = false
|
||||
thresholdCrossed.value = false
|
||||
animatePositionToRest()
|
||||
}
|
||||
|
||||
function beginPointerSwipe(event: PointerEvent) {
|
||||
if (!props.enabled || props.input !== 'pointer')
|
||||
return
|
||||
|
||||
if (!event.isPrimary || (event.pointerType === 'mouse' && event.button !== 0))
|
||||
return
|
||||
|
||||
activePointerId = event.pointerId
|
||||
pointerStartX = event.clientX
|
||||
pointerStartY = event.clientY
|
||||
returnAnimation?.cancel()
|
||||
}
|
||||
|
||||
function updatePointerSwipe(event: PointerEvent) {
|
||||
if (event.pointerId !== activePointerId)
|
||||
return
|
||||
|
||||
const deltaX = pointerStartX - event.clientX
|
||||
const deltaY = Math.abs(event.clientY - pointerStartY)
|
||||
const distance = directedDistance(deltaX)
|
||||
if (Math.max(Math.abs(deltaX), deltaY) < props.startDistance)
|
||||
return
|
||||
|
||||
if (distance <= 0 || deltaY >= distance) {
|
||||
setGestureDistance(0)
|
||||
return
|
||||
}
|
||||
|
||||
// The nested action menu must receive the first move before this ancestor
|
||||
// captures the pointer. Reka uses that move to cancel its long-press timer.
|
||||
if (!active.value && event.isTrusted)
|
||||
rootRef.value?.setPointerCapture(event.pointerId)
|
||||
|
||||
returnAnimation?.cancel()
|
||||
active.value = true
|
||||
setGestureDistance(distance)
|
||||
}
|
||||
|
||||
function finishPointerSwipe(event: PointerEvent) {
|
||||
if (event.pointerId !== activePointerId)
|
||||
return
|
||||
|
||||
activePointerId = undefined
|
||||
if (props.enabled && thresholdCrossed.value)
|
||||
emit('commit')
|
||||
resetPosition()
|
||||
}
|
||||
|
||||
function cancelPointerSwipe(event: PointerEvent) {
|
||||
if (event.pointerId !== activePointerId)
|
||||
return
|
||||
|
||||
activePointerId = undefined
|
||||
resetPosition()
|
||||
}
|
||||
|
||||
function finishWheelSwipe() {
|
||||
if (!wheelSessionActive)
|
||||
return
|
||||
|
||||
wheelSessionActive = false
|
||||
const shouldCommit = wheelIntent === 'horizontal' && props.enabled && thresholdCrossed.value
|
||||
|
||||
wheelIntent = 'pending'
|
||||
wheelDistance = 0
|
||||
resetPosition()
|
||||
|
||||
if (shouldCommit)
|
||||
emit('commit')
|
||||
}
|
||||
|
||||
function cancelGesture() {
|
||||
activePointerId = undefined
|
||||
wheelIntent = 'pending'
|
||||
wheelSessionActive = false
|
||||
wheelDistance = 0
|
||||
resetPosition()
|
||||
}
|
||||
|
||||
function updateWheelSwipe(state: NonNullable<typeof wheelGesture.value>) {
|
||||
if (state.isEnding) {
|
||||
finishWheelSwipe()
|
||||
return
|
||||
}
|
||||
|
||||
if (state.isStart) {
|
||||
wheelSessionActive = true
|
||||
wheelIntent = 'pending'
|
||||
wheelDistance = 0
|
||||
}
|
||||
|
||||
// Momentum begins after the fingers leave the trackpad. The reply action
|
||||
// uses the direct-pan position and gives the inertial tail to the return.
|
||||
if (state.isMomentum) {
|
||||
finishWheelSwipe()
|
||||
return
|
||||
}
|
||||
|
||||
if (wheelIntent === 'vertical')
|
||||
return
|
||||
|
||||
const [movementX, movementY] = state.axisMovement
|
||||
const distance = directedDistance(movementX)
|
||||
if (wheelIntent === 'pending') {
|
||||
// One sample near the jitter boundary cannot establish a trackpad axis.
|
||||
// Accumulate two start distances so later vertical movement can keep scrolling.
|
||||
const intentDistance = Math.min(props.threshold, props.startDistance * 2)
|
||||
if (Math.max(Math.abs(movementX), Math.abs(movementY)) < intentDistance)
|
||||
return
|
||||
|
||||
const absoluteDeltaX = Math.abs(movementX)
|
||||
const absoluteDeltaY = Math.abs(movementY)
|
||||
// A 1.5:1 ratio leaves diagonal input pending until the user's intended
|
||||
// axis is clear. A vertical or opposite-direction lock lasts until idle.
|
||||
if (absoluteDeltaY >= absoluteDeltaX * 1.5
|
||||
|| (distance <= 0 && absoluteDeltaX >= absoluteDeltaY * 1.5)) {
|
||||
wheelIntent = 'vertical'
|
||||
return
|
||||
}
|
||||
|
||||
if (distance < absoluteDeltaY * 1.5)
|
||||
return
|
||||
|
||||
wheelIntent = 'horizontal'
|
||||
active.value = true
|
||||
returnAnimation?.cancel()
|
||||
wheelDistance = Math.max(0, distance)
|
||||
}
|
||||
else {
|
||||
wheelDistance = Math.max(
|
||||
0,
|
||||
wheelDistance + directedDistance(state.axisDelta[0]),
|
||||
)
|
||||
}
|
||||
|
||||
if (state.event instanceof WheelEvent && state.event.cancelable)
|
||||
state.event.preventDefault()
|
||||
|
||||
active.value = true
|
||||
returnAnimation?.cancel()
|
||||
setGestureDistance(wheelDistance)
|
||||
}
|
||||
|
||||
useEventListener(rootRef, 'pointerdown', beginPointerSwipe, { passive: true })
|
||||
useEventListener(rootRef, 'pointermove', updatePointerSwipe, { passive: true })
|
||||
useEventListener(rootRef, 'pointerup', finishPointerSwipe, { passive: true })
|
||||
useEventListener(rootRef, ['pointercancel', 'lostpointercapture'], cancelPointerSwipe, { passive: true })
|
||||
watch(wheelGesture, (state) => {
|
||||
if (state)
|
||||
updateWheelSwipe(state)
|
||||
}, { flush: 'sync' })
|
||||
|
||||
watch(() => [props.enabled, props.input, props.direction], cancelGesture)
|
||||
|
||||
onUnmounted(() => {
|
||||
returnAnimation?.cancel()
|
||||
if (positionFrame !== undefined)
|
||||
cancelAnimationFrame(positionFrame)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
data-swipeable
|
||||
:data-swipe-active="active"
|
||||
:style="{
|
||||
touchAction: enabled && input === 'pointer' ? 'pan-y' : undefined,
|
||||
}"
|
||||
:class="['relative']"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope, nextTick, shallowRef } from 'vue'
|
||||
|
||||
import { useSwipeGesture } from './use-swipe-gesture'
|
||||
|
||||
describe('useSwipeGesture', () => {
|
||||
it('exposes native movement immediately and an adaptive ending state', async () => {
|
||||
vi.useFakeTimers()
|
||||
const scope = effectScope()
|
||||
|
||||
try {
|
||||
const element = document.createElement('div')
|
||||
const target = shallowRef<HTMLElement | null>(element)
|
||||
const stream = scope.run(() => useSwipeGesture(target))
|
||||
if (!stream)
|
||||
throw new Error('Expected a swipe gesture.')
|
||||
await nextTick()
|
||||
|
||||
const createWheelEvent = (deltaX: number, timeStamp: number) => {
|
||||
const event = new WheelEvent('wheel', { deltaX })
|
||||
Object.defineProperty(event, 'timeStamp', { value: timeStamp })
|
||||
return event
|
||||
}
|
||||
|
||||
const firstEvent = createWheelEvent(20, 0)
|
||||
element.dispatchEvent(firstEvent)
|
||||
|
||||
expect(stream.state.value?.event).toBe(firstEvent)
|
||||
expect(stream.state.value).toMatchObject({
|
||||
axisDelta: [20, 0, 0],
|
||||
axisMovement: [20, 0, 0],
|
||||
isEnding: false,
|
||||
isStart: true,
|
||||
})
|
||||
|
||||
for (const [deltaX, timeStamp] of [[12, 10], [8, 20], [4, 30]] as const)
|
||||
element.dispatchEvent(createWheelEvent(deltaX, timeStamp))
|
||||
|
||||
expect(stream.state.value).toMatchObject({
|
||||
axisMovement: [44, 0, 0],
|
||||
isEnding: false,
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(99)
|
||||
expect(stream.state.value?.isEnding).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(stream.state.value).toMatchObject({
|
||||
axisDelta: [0, 0, 0],
|
||||
axisMovement: [44, 0, 0],
|
||||
isEnding: true,
|
||||
})
|
||||
}
|
||||
finally {
|
||||
scope.stop()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not feed filtered wheel events into the gesture', async () => {
|
||||
const scope = effectScope()
|
||||
|
||||
try {
|
||||
const element = document.createElement('div')
|
||||
const target = shallowRef<HTMLElement | null>(element)
|
||||
const stream = scope.run(() => useSwipeGesture(target, {
|
||||
filter: event => !event.ctrlKey,
|
||||
}))
|
||||
if (!stream)
|
||||
throw new Error('Expected a swipe gesture.')
|
||||
await nextTick()
|
||||
|
||||
element.dispatchEvent(new WheelEvent('wheel', { ctrlKey: true, deltaX: 20 }))
|
||||
|
||||
expect(stream.state.value).toBeUndefined()
|
||||
}
|
||||
finally {
|
||||
scope.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { MaybeRefOrGetter, ShallowRef } from 'vue'
|
||||
import type { WheelEventState } from 'wheel-gestures'
|
||||
|
||||
import { tryOnScopeDispose, useEventListener } from '@vueuse/core'
|
||||
import { shallowRef } from 'vue'
|
||||
import { WheelGestures } from 'wheel-gestures'
|
||||
|
||||
/** Configuration for a native wheel swipe gesture. */
|
||||
export interface UseSwipeGestureOptions {
|
||||
/** Ignores events that do not belong to the gesture. @default accepts every event */
|
||||
filter?: (event: WheelEvent) => boolean
|
||||
}
|
||||
|
||||
/** Reactive view of one normalized native wheel swipe gesture. */
|
||||
export interface UseSwipeGestureReturn {
|
||||
/** The latest movement, velocity, momentum, and adaptive ending state. */
|
||||
state: Readonly<ShallowRef<WheelEventState | undefined>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks a native wheel gesture without claiming the browser's scroll action.
|
||||
*
|
||||
* The consumer decides when horizontal intent is strong enough to call
|
||||
* `preventDefault()`. Wheel Gestures supplies normalized deltas, velocity,
|
||||
* momentum detection, and an adaptive ending signal.
|
||||
*/
|
||||
export function useSwipeGesture(
|
||||
target: MaybeRefOrGetter<HTMLElement | null | undefined>,
|
||||
options: UseSwipeGestureOptions = {},
|
||||
): UseSwipeGestureReturn {
|
||||
const state = shallowRef<WheelEventState>()
|
||||
const wheelGestures = WheelGestures({
|
||||
preventWheelAction: false,
|
||||
reverseSign: false,
|
||||
})
|
||||
const stopStateListener = wheelGestures.on('wheel', (nextState) => {
|
||||
state.value = nextState
|
||||
})
|
||||
|
||||
useEventListener(target, 'wheel', (nextEvent) => {
|
||||
if (options.filter && !options.filter(nextEvent))
|
||||
return
|
||||
|
||||
wheelGestures.feedWheel(nextEvent)
|
||||
}, { passive: false })
|
||||
|
||||
tryOnScopeDispose(() => {
|
||||
stopStateListener()
|
||||
wheelGestures.disconnect()
|
||||
})
|
||||
|
||||
return {
|
||||
state,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './auth'
|
||||
export * from './data-pane'
|
||||
export * from './gadgets'
|
||||
export * from './gestures'
|
||||
export * from './graphics'
|
||||
export * from './layouts'
|
||||
export * from './markdown'
|
||||
|
||||
+7
-43
@@ -2,63 +2,34 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createChatActionMenuItems, createChatActionMenuTriggerState } 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', () => {
|
||||
it('orders reply before the existing message actions', () => {
|
||||
const items = createChatActionMenuItems({
|
||||
canReply: true,
|
||||
canCopy: true,
|
||||
canRetry: true,
|
||||
canDelete: true,
|
||||
replyLabel: 'Reply',
|
||||
})
|
||||
|
||||
expect(items.map(item => item.action)).toEqual(['copy', 'retry', 'delete'])
|
||||
expect(items[1]?.label).toBe('Retry')
|
||||
expect(items.map(item => item.action)).toEqual(['reply', 'copy', 'retry', 'delete'])
|
||||
expect(items[2]?.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({
|
||||
canReply: false,
|
||||
canCopy: true,
|
||||
canRetry: false,
|
||||
canDelete: true,
|
||||
replyLabel: 'Reply',
|
||||
})
|
||||
|
||||
expect(items.map(item => item.action)).toEqual(['copy', 'delete'])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* describe('createChatActionMenuTriggerState', () => {
|
||||
* it('uses a success checkmark while copy feedback is active', () => {})
|
||||
* })
|
||||
*/
|
||||
describe('createChatActionMenuTriggerState', () => {
|
||||
/**
|
||||
* @example
|
||||
* it('uses a success checkmark while copy feedback is active', () => {
|
||||
* const state = createChatActionMenuTriggerState({ copyFeedbackActive: true })
|
||||
* expect(state.tone).toBe('success')
|
||||
* })
|
||||
*/
|
||||
it('uses a success checkmark while copy feedback is active', () => {
|
||||
const state = createChatActionMenuTriggerState({ copyFeedbackActive: true })
|
||||
|
||||
@@ -66,13 +37,6 @@ describe('createChatActionMenuTriggerState', () => {
|
||||
expect(state.tone).toBe('success')
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* it('uses the default menu icon without copy feedback', () => {
|
||||
* const state = createChatActionMenuTriggerState({})
|
||||
* expect(state.tone).toBe('default')
|
||||
* })
|
||||
*/
|
||||
it('uses the default menu icon without copy feedback', () => {
|
||||
const state = createChatActionMenuTriggerState({})
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { MaybeComputedElementRef } from '@vueuse/core'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import type { ComponentPublicInstance, ComputedRef, ShallowRef } from 'vue'
|
||||
|
||||
import type { ChatActionMenuAction } from '.'
|
||||
|
||||
import { errorMessageFromValue, isStageCapacitor, isStageWeb } from '@proj-airi/stage-shared'
|
||||
import { useElementVisibility, useIntervalFn } from '@vueuse/core'
|
||||
import { useElementVisibility, useEventListener } from '@vueuse/core'
|
||||
import { animate } from 'animejs'
|
||||
import { clamp } from 'es-toolkit'
|
||||
import {
|
||||
@@ -20,7 +19,7 @@ import {
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger,
|
||||
} from 'reka-ui'
|
||||
import { computed, onUnmounted, reactive, ref, shallowRef, toRef, useTemplateRef, watch } from 'vue'
|
||||
import { computed, onUnmounted, reactive, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useWebHaptics } from 'web-haptics/vue'
|
||||
|
||||
@@ -30,24 +29,29 @@ import { useElementScroll } from '../../composables/use-element-scroll'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
canCopy?: boolean
|
||||
canReply?: boolean
|
||||
canRetry?: boolean
|
||||
canDelete?: boolean
|
||||
copyText?: string
|
||||
menuLabel?: string
|
||||
placement?: 'left' | 'right'
|
||||
pressFeedbackEnabled?: boolean
|
||||
scrollContainer?: HTMLElement | null
|
||||
}>(), {
|
||||
canCopy: true,
|
||||
canReply: false,
|
||||
canRetry: false,
|
||||
canDelete: true,
|
||||
copyText: '',
|
||||
menuLabel: 'Message actions',
|
||||
placement: 'right',
|
||||
pressFeedbackEnabled: false,
|
||||
scrollContainer: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'copy'): void
|
||||
(e: 'reply'): void
|
||||
(e: 'retry'): void
|
||||
(e: 'delete'): void
|
||||
}>()
|
||||
@@ -86,13 +90,16 @@ const { trigger } = useWebHaptics()
|
||||
const { isMobile } = useBreakpoints()
|
||||
const { t } = useI18n()
|
||||
const shouldDisableDropdownMenu = computed(() => (isStageWeb() || isStageCapacitor()) && isMobile.value)
|
||||
const pressFeedbackEnabled = computed(() => props.pressFeedbackEnabled)
|
||||
const copyFeedbackActive = shallowRef(false)
|
||||
|
||||
const menuItems = computed(() => createChatActionMenuItems({
|
||||
canReply: props.canReply,
|
||||
canCopy: props.canCopy && props.copyText.trim().length > 0,
|
||||
canRetry: props.canRetry,
|
||||
canDelete: props.canDelete,
|
||||
retryLabel: t('stage.chat.actions.retry'),
|
||||
replyLabel: t('stage.chat.actions.reply'),
|
||||
}))
|
||||
const triggerState = computed(() => createChatActionMenuTriggerState({
|
||||
copyFeedbackActive: copyFeedbackActive.value,
|
||||
@@ -148,70 +155,57 @@ function setMeasuredElement(element: Element | ComponentPublicInstance | null) {
|
||||
measuredElementRef.value = element instanceof HTMLElement ? element : null
|
||||
}
|
||||
|
||||
function useTouching(element: MaybeComputedElementRef) {
|
||||
const elementRef = toRef(element)
|
||||
/** Cancels long-press feedback after 8 pixels of touch travel. */
|
||||
const PRESS_CANCEL_DISTANCE_PX = 8
|
||||
|
||||
const pressStartTime = ref(0)
|
||||
const pressNow = ref(0)
|
||||
function usePressing(
|
||||
elementRef: Readonly<ShallowRef<HTMLElement | null>>,
|
||||
enabled: Readonly<ComputedRef<boolean>>,
|
||||
) {
|
||||
const isPressing = shallowRef(false)
|
||||
let pointerId: number | undefined
|
||||
let pointerStartX = 0
|
||||
let pointerStartY = 0
|
||||
|
||||
const { resume, pause } = useIntervalFn(() => pressNow.value = Date.now(), 50)
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!enabled.value || event.pointerType !== 'touch' || !event.isPrimary)
|
||||
return
|
||||
|
||||
const isTouching = ref(false)
|
||||
const pressedFor = computed(() => {
|
||||
if (!isTouching.value || pressStartTime.value === 0)
|
||||
return 0
|
||||
pointerId = event.pointerId
|
||||
pointerStartX = event.clientX
|
||||
pointerStartY = event.clientY
|
||||
isPressing.value = true
|
||||
}
|
||||
|
||||
const result = pressNow.value - pressStartTime.value
|
||||
if (result < 0)
|
||||
return 0
|
||||
function handlePointerEnd(event?: PointerEvent) {
|
||||
if (event && event.pointerId !== pointerId)
|
||||
return
|
||||
|
||||
return result
|
||||
pointerId = undefined
|
||||
isPressing.value = false
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (event.pointerId !== pointerId)
|
||||
return
|
||||
|
||||
const distance = Math.hypot(event.clientX - pointerStartX, event.clientY - pointerStartY)
|
||||
if (distance > PRESS_CANCEL_DISTANCE_PX)
|
||||
handlePointerEnd(event)
|
||||
}
|
||||
|
||||
useEventListener(elementRef, 'pointerdown', handlePointerDown, { passive: true })
|
||||
// Track travel at window level before and after the surrounding swipe surface
|
||||
// captures a confirmed horizontal gesture.
|
||||
useEventListener(window, 'pointermove', handlePointerMove, { passive: true })
|
||||
useEventListener(window, ['pointerup', 'pointercancel'], handlePointerEnd, { passive: true })
|
||||
watch(enabled, (canPress) => {
|
||||
if (!canPress)
|
||||
handlePointerEnd()
|
||||
})
|
||||
|
||||
function handleTouchStart() {
|
||||
isTouching.value = true
|
||||
pressStartTime.value = Date.now()
|
||||
resume()
|
||||
}
|
||||
|
||||
function handleTouchMove() {
|
||||
isTouching.value = true
|
||||
}
|
||||
|
||||
function handleTouchEnd() {
|
||||
isTouching.value = false
|
||||
pressStartTime.value = 0
|
||||
pause()
|
||||
}
|
||||
|
||||
function handleTouchCancel() {
|
||||
isTouching.value = false
|
||||
pressStartTime.value = 0
|
||||
pause()
|
||||
}
|
||||
|
||||
watch(elementRef, (newElement) => {
|
||||
if (newElement) {
|
||||
const el = newElement as HTMLElement
|
||||
|
||||
el.addEventListener('touchstart', handleTouchStart, { passive: true })
|
||||
el.addEventListener('touchmove', handleTouchMove, { passive: true })
|
||||
el.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||
el.addEventListener('touchcancel', handleTouchCancel, { passive: true })
|
||||
}
|
||||
else if (elementRef.value) {
|
||||
const el = elementRef.value as HTMLElement
|
||||
|
||||
el.removeEventListener('touchstart', handleTouchStart)
|
||||
el.removeEventListener('touchmove', handleTouchMove)
|
||||
el.removeEventListener('touchend', handleTouchEnd)
|
||||
el.removeEventListener('touchcancel', handleTouchCancel)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
return {
|
||||
isTouching,
|
||||
pressedFor,
|
||||
isPressing,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,13 +239,18 @@ function useSetTimeoutFn(fn: () => void, options?: { delay?: number, onClear?: (
|
||||
}
|
||||
}
|
||||
|
||||
const { isTouching } = useTouching(contextMenuContainerElementRef)
|
||||
const { isPressing } = usePressing(contextMenuContainerElementRef, pressFeedbackEnabled)
|
||||
|
||||
const { trigger: triggerCopyFeedbackReset, clear: clearCopyFeedbackReset } = useSetTimeoutFn(() => {
|
||||
copyFeedbackActive.value = false
|
||||
}, { delay: 1000 })
|
||||
|
||||
async function handleAction(action: ChatActionMenuAction) {
|
||||
if (action === 'reply') {
|
||||
emit('reply')
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'copy') {
|
||||
if (!props.copyText.trim())
|
||||
return
|
||||
@@ -310,8 +309,8 @@ const { trigger: triggerTimer, clear: clearTimer } = useSetTimeoutFn(() => {
|
||||
trigger('medium')
|
||||
}, { delay: contextMenuPressOpenDelay })
|
||||
|
||||
watch(isTouching, (touching) => {
|
||||
if (touching) {
|
||||
watch(isPressing, (pressing) => {
|
||||
if (pressing) {
|
||||
animatePressedState()
|
||||
triggerTimer()
|
||||
return
|
||||
@@ -332,6 +331,7 @@ onUnmounted(() => scaleAnimation?.cancel())
|
||||
<ContextMenuTrigger as-child>
|
||||
<div
|
||||
ref="contextMenuContainer"
|
||||
:data-pressing="isPressing"
|
||||
:class="[
|
||||
'group/chat-action relative w-fit',
|
||||
]"
|
||||
|
||||
+11
-2
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Represents supported chat message action identifiers.
|
||||
*/
|
||||
export type ChatActionMenuAction = 'copy' | 'retry' | 'delete'
|
||||
export type ChatActionMenuAction = 'reply' | 'copy' | 'retry' | 'delete'
|
||||
|
||||
/**
|
||||
* Represents one visible action in a chat message action menu.
|
||||
@@ -50,15 +50,24 @@ export interface ChatActionMenuTriggerState {
|
||||
* - Boolean flags already reflect message capability and visibility rules
|
||||
*
|
||||
* Returns:
|
||||
* - Menu items ordered as copy, retry, delete
|
||||
* - Menu items ordered as reply, copy, retry, delete
|
||||
*/
|
||||
export function createChatActionMenuItems(options: {
|
||||
canReply: boolean
|
||||
canCopy: boolean
|
||||
canRetry: boolean
|
||||
canDelete: boolean
|
||||
retryLabel?: string
|
||||
replyLabel: string
|
||||
}): ChatActionMenuItem[] {
|
||||
return [
|
||||
options.canReply
|
||||
? {
|
||||
action: 'reply',
|
||||
label: options.replyLabel,
|
||||
icon: 'i-solar:reply-bold',
|
||||
}
|
||||
: null,
|
||||
options.canCopy
|
||||
? {
|
||||
action: 'copy',
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChatAssistantMessage, ChatHistoryItem, ChatSlices, ChatSlicesText, ChatSlicesToolCallResult } from '../../../../types/chat'
|
||||
import type { ChatHistoryReplyPayload } from '../reply'
|
||||
import type { ChatToolCallRendererRegistry } from './tool-call-renderer'
|
||||
|
||||
import { isStageCapacitor, isStageWeb } from '@proj-airi/stage-shared'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ChatReplyQuote from './reply-quote.vue'
|
||||
import ChatResponsePart from './response-part.vue'
|
||||
import ChatToolCallBlock from './tool-call-block.vue'
|
||||
|
||||
@@ -16,11 +18,14 @@ import { createToolCallResultLookup, resolveToolCallBlockState } from './tool-ca
|
||||
const props = withDefaults(defineProps<{
|
||||
message: ChatAssistantMessage
|
||||
label: string
|
||||
replyTarget?: ChatHistoryReplyPayload
|
||||
canReply?: boolean
|
||||
scrollContainer?: HTMLElement | null
|
||||
showPlaceholder?: boolean
|
||||
variant?: 'desktop' | 'mobile'
|
||||
toolCallRenderers?: ChatToolCallRendererRegistry
|
||||
}>(), {
|
||||
canReply: false,
|
||||
showPlaceholder: false,
|
||||
scrollContainer: null,
|
||||
variant: 'desktop',
|
||||
@@ -30,6 +35,7 @@ const props = withDefaults(defineProps<{
|
||||
const emit = defineEmits<{
|
||||
(e: 'copy'): void
|
||||
(e: 'delete'): void
|
||||
(e: 'reply'): void
|
||||
(e: 'toolCallRerun', payload: { toolCallId: string, toolName: string, args: string }): void
|
||||
}>()
|
||||
|
||||
@@ -89,10 +95,13 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat
|
||||
<div flex :class="['font-cute', containerClass]" class="ph-no-capture">
|
||||
<ChatActionMenu
|
||||
:copy-text="copyText"
|
||||
:can-reply="canReply"
|
||||
:can-delete="!showPlaceholder"
|
||||
:press-feedback-enabled="variant === 'mobile'"
|
||||
:scroll-container="scrollContainer"
|
||||
@copy="emit('copy')"
|
||||
@delete="emit('delete')"
|
||||
@reply="emit('reply')"
|
||||
>
|
||||
<template #default="{ setMeasuredElement }">
|
||||
<div
|
||||
@@ -105,6 +114,7 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat
|
||||
(isStageWeb() || isStageCapacitor()) && props.variant === 'mobile' ? 'select-none sm:select-auto' : '',
|
||||
]"
|
||||
>
|
||||
<ChatReplyQuote v-if="replyTarget" :target="replyTarget" />
|
||||
<ChatResponsePart
|
||||
v-if="message.categorization"
|
||||
:message="message"
|
||||
|
||||
+61
-2
@@ -1,33 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import type { SwipeableSlotProps } from '../../../gestures'
|
||||
|
||||
import { useElementVisibility } from '@vueuse/core'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
import { useWebHaptics } from 'web-haptics/vue'
|
||||
|
||||
import { Swipeable } from '../../../gestures'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
scrollContainer?: HTMLElement | null
|
||||
variant?: 'desktop' | 'mobile'
|
||||
replyEnabled?: boolean
|
||||
}>(), {
|
||||
replyEnabled: false,
|
||||
scrollContainer: null,
|
||||
variant: 'desktop',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
reply: []
|
||||
}>()
|
||||
|
||||
const messageRef = useTemplateRef<HTMLDivElement>('message')
|
||||
const scrollTarget = computed(() => props.scrollContainer)
|
||||
const isVisible = useElementVisibility(messageRef, {
|
||||
initialValue: false,
|
||||
scrollTarget,
|
||||
})
|
||||
|
||||
const { trigger: triggerHaptic } = useWebHaptics()
|
||||
|
||||
function getReplyIconStyle(swipe: SwipeableSlotProps) {
|
||||
const offset = Math.abs(swipe.offset)
|
||||
// The icon moves at 18% of the bubble distance, with a 10-pixel cap.
|
||||
const iconOffset = -Math.min(offset * 0.18, 10)
|
||||
return {
|
||||
opacity: swipe.progress,
|
||||
// Scale from 72% to full size as the pointer reaches the reply threshold.
|
||||
transform: `translate3d(${iconOffset}px, -50%, 0) scale(${0.72 + swipe.progress * 0.28})`,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="message"
|
||||
:class="[
|
||||
'chat-message-item',
|
||||
'chat-message-item relative',
|
||||
'opacity-0 transition-opacity duration-200 ease-out motion-reduce:transition-none',
|
||||
isVisible ? 'chat-message-item-visible opacity-100' : '',
|
||||
variant === 'mobile' ? 'pb-1' : 'pb-2',
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
<Swipeable
|
||||
v-slot="swipe"
|
||||
:enabled="replyEnabled"
|
||||
:input="variant === 'mobile' ? 'pointer' : 'wheel'"
|
||||
@commit="emit('reply')"
|
||||
@threshold-enter="triggerHaptic('medium')"
|
||||
>
|
||||
<div
|
||||
v-if="replyEnabled"
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
'pointer-events-none absolute top-1/2 z-0 size-8',
|
||||
'flex items-center justify-center rounded-full',
|
||||
'bg-primary-100/85 text-primary-600 shadow-sm backdrop-blur-sm',
|
||||
'dark:bg-primary-900/80 dark:text-primary-200',
|
||||
'right-1',
|
||||
]"
|
||||
:style="getReplyIconStyle(swipe)"
|
||||
>
|
||||
<div class="i-solar:reply-bold-duotone size-4" />
|
||||
</div>
|
||||
<div
|
||||
data-swipeable-surface
|
||||
:data-swipe-active="swipe.active"
|
||||
:class="[
|
||||
'relative z-1',
|
||||
swipe.active ? 'select-none' : '',
|
||||
]"
|
||||
:style="{
|
||||
transform: `translate3d(${swipe.offset}px, 0, 0)`,
|
||||
willChange: swipe.active ? 'transform' : undefined,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</Swipeable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
import type { VirtualizerHandle } from 'virtua/vue'
|
||||
|
||||
import type { ChatHistoryItem, StreamingAssistantMessage } from '../../../../types/chat'
|
||||
import type { ChatHistoryReplyPayload } from '../reply'
|
||||
import type { ChatToolCallRendererRegistry } from './tool-call-renderer'
|
||||
|
||||
import { Virtualizer } from 'virtua/vue'
|
||||
@@ -17,7 +18,7 @@ import ChatUserItem from './user-item.vue'
|
||||
import { useChatHistoryScroll } from '../composables/use-chat-history-scroll'
|
||||
import { useChatHistoryTopFade } from '../composables/use-chat-history-top-fade'
|
||||
import { useVirtualizerBottomAlignment, useVirtualizerScroll } from '../composables/use-virtualizer-scroll'
|
||||
import { getChatHistoryItemKey } from '../utils'
|
||||
import { getChatHistoryItemCopyText, getChatHistoryItemKey } from '../utils'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
@@ -31,10 +32,13 @@ const props = withDefaults(defineProps<{
|
||||
userLabel?: string
|
||||
errorLabel?: string
|
||||
retryLabel?: string
|
||||
/** Space that a floating composer covers at the end of the scroll viewport. */
|
||||
tailInset?: number
|
||||
variant?: 'desktop' | 'mobile'
|
||||
toolCallRenderers?: ChatToolCallRendererRegistry
|
||||
}>(), {
|
||||
sending: false,
|
||||
tailInset: 0,
|
||||
variant: 'desktop',
|
||||
toolCallRenderers: () => ({}),
|
||||
})
|
||||
@@ -42,6 +46,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: 'replyMessage', payload: ChatHistoryReplyPayload): void
|
||||
(e: 'retryMessage', payload: { message: ChatHistoryItem, index: number, key: string | number }): void
|
||||
(e: 'toolCallRerun', payload: { message: ChatHistoryItem, index: number, key: string | number, toolCallId: string, toolName: string, args: string }): void
|
||||
}>()
|
||||
@@ -52,7 +57,11 @@ const CHAT_HISTORY_OVERSCAN = 600
|
||||
const scrollContainerRef = useTemplateRef<InstanceType<typeof ChatHistoryScrollContainer>>('scroll-container')
|
||||
const chatHistoryRef = computed<HTMLElement | null>(() => scrollContainerRef.value?.viewport ?? null)
|
||||
const virtualizerRef = useTemplateRef<VirtualizerHandle>('virtualizer')
|
||||
const { scrollToIndex } = useVirtualizerScroll(virtualizerRef)
|
||||
const tailInset = computed(() => props.tailInset)
|
||||
const { scrollToIndex } = useVirtualizerScroll({
|
||||
tailInset,
|
||||
virtualizer: virtualizerRef,
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const labels = computed(() => ({
|
||||
@@ -67,6 +76,18 @@ const showStreamingPlaceholder = computed(() => (streaming.value.slices?.length
|
||||
function shouldShowPlaceholder(message: ChatHistoryItem) {
|
||||
return !!streaming.value.id && message.id === streaming.value.id
|
||||
}
|
||||
function canReplyToMessage(message: ChatHistoryItem) {
|
||||
if (!message.id)
|
||||
return false
|
||||
|
||||
if (message.role !== 'assistant' && message.role !== 'user')
|
||||
return false
|
||||
|
||||
if (message.role === 'assistant' && shouldShowPlaceholder(message) && showStreamingPlaceholder.value)
|
||||
return false
|
||||
|
||||
return getChatHistoryItemCopyText(message).trim().length > 0
|
||||
}
|
||||
const renderMessages = computed<ChatHistoryItem[]>(() => {
|
||||
if (!props.sending)
|
||||
return props.messages
|
||||
@@ -81,6 +102,9 @@ const renderMessages = computed<ChatHistoryItem[]>(() => {
|
||||
|
||||
return [...props.messages, streaming.value]
|
||||
})
|
||||
const messagesById = computed(() => new Map(
|
||||
renderMessages.value.flatMap(message => message.id ? [[message.id, message] as const] : []),
|
||||
))
|
||||
const renderMessageCount = computed(() => renderMessages.value.length)
|
||||
const topFadeRatio = computed(() => props.variant === 'mobile' ? 0.2 : 0)
|
||||
|
||||
@@ -95,6 +119,7 @@ useChatHistoryScroll({
|
||||
messages: renderMessages,
|
||||
getKey: getChatHistoryItemKey,
|
||||
scrollToIndex,
|
||||
tailInset,
|
||||
})
|
||||
useChatHistoryTopFade({
|
||||
container: chatHistoryRef,
|
||||
@@ -125,6 +150,30 @@ function emitRetryMessage(message: ChatHistoryItem, index: number) {
|
||||
})
|
||||
}
|
||||
|
||||
function emitReplyMessage(message: ChatHistoryItem) {
|
||||
if (!canReplyToMessage(message))
|
||||
return
|
||||
|
||||
emit('replyMessage', {
|
||||
message,
|
||||
label: message.role === 'assistant' ? labels.value.assistant : labels.value.user,
|
||||
})
|
||||
}
|
||||
|
||||
function getReplyTarget(message: ChatHistoryItem): ChatHistoryReplyPayload | undefined {
|
||||
if (!message.replyToMessageId)
|
||||
return undefined
|
||||
|
||||
const target = messagesById.value.get(message.replyToMessageId)
|
||||
if (!target || (target.role !== 'assistant' && target.role !== 'user'))
|
||||
return undefined
|
||||
|
||||
return {
|
||||
label: target.role === 'assistant' ? labels.value.assistant : labels.value.user,
|
||||
message: target,
|
||||
}
|
||||
}
|
||||
|
||||
function emitToolCallRerun(
|
||||
message: ChatHistoryItem,
|
||||
index: number,
|
||||
@@ -157,6 +206,8 @@ function emitToolCallRerun(
|
||||
:key="getChatHistoryItemKey(message, index)"
|
||||
:variant="variant"
|
||||
:scroll-container="chatHistoryRef"
|
||||
:reply-enabled="canReplyToMessage(message)"
|
||||
@reply="emitReplyMessage(message)"
|
||||
>
|
||||
<ChatErrorItem
|
||||
v-if="message.role === 'error'"
|
||||
@@ -175,22 +226,28 @@ function emitToolCallRerun(
|
||||
v-else-if="message.role === 'assistant'"
|
||||
:message="message"
|
||||
:label="labels.assistant"
|
||||
:reply-target="getReplyTarget(message)"
|
||||
:can-reply="canReplyToMessage(message)"
|
||||
:show-placeholder="shouldShowPlaceholder(message) && showStreamingPlaceholder"
|
||||
:scroll-container="chatHistoryRef"
|
||||
:variant="variant"
|
||||
:tool-call-renderers="toolCallRenderers"
|
||||
@copy="emitCopyMessage(message, index)"
|
||||
@delete="emitDeleteMessage(message, index)"
|
||||
@reply="emitReplyMessage(message)"
|
||||
@tool-call-rerun="emitToolCallRerun(message, index, $event)"
|
||||
/>
|
||||
<ChatUserItem
|
||||
v-else-if="message.role === 'user'"
|
||||
:message="message"
|
||||
:label="labels.user"
|
||||
:reply-target="getReplyTarget(message)"
|
||||
:can-reply="canReplyToMessage(message)"
|
||||
:scroll-container="chatHistoryRef"
|
||||
:variant="variant"
|
||||
@copy="emitCopyMessage(message, index)"
|
||||
@delete="emitDeleteMessage(message, index)"
|
||||
@reply="emitReplyMessage(message)"
|
||||
/>
|
||||
</ChatHistoryMessageFrame>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChatHistoryReplyPayload } from '../reply'
|
||||
|
||||
import { IconButton } from '@proj-airi/ui'
|
||||
import { computed, nextTick, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { getChatReplyPreview } from '../reply'
|
||||
|
||||
const props = defineProps<{
|
||||
target?: ChatHistoryReplyPayload
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const contentRef = useTemplateRef<HTMLElement>('content')
|
||||
const renderedTarget = shallowRef(props.target)
|
||||
const expandedHeight = shallowRef(0)
|
||||
const preview = computed(() => renderedTarget.value ? getChatReplyPreview(renderedTarget.value) : '')
|
||||
const transitionStyle = computed(() => ({
|
||||
maxHeight: props.target ? `${expandedHeight.value}px` : '0px',
|
||||
}))
|
||||
|
||||
watch(() => props.target, async (target) => {
|
||||
// Keep the previous content measurable while the outer region animates closed.
|
||||
if (!target)
|
||||
return
|
||||
|
||||
renderedTarget.value = target
|
||||
await nextTick()
|
||||
expandedHeight.value = contentRef.value?.scrollHeight ?? 0
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:style="transitionStyle"
|
||||
:class="[
|
||||
'overflow-hidden transition-[max-height,opacity] duration-200 ease-out motion-reduce:transition-none',
|
||||
target ? 'opacity-100' : 'opacity-0',
|
||||
target ? '' : 'pointer-events-none',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
ref="content"
|
||||
:aria-hidden="!target"
|
||||
:class="[
|
||||
'min-w-0 flex items-center gap-2 px-3 py-2 text-left',
|
||||
'border-b border-primary-200/30 dark:border-primary-700/30',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
'i-solar:reply-bold-duotone size-4 shrink-0',
|
||||
'text-primary-500 dark:text-primary-300',
|
||||
]"
|
||||
/>
|
||||
<div :class="['min-w-0 flex flex-1 flex-col leading-tight']">
|
||||
<span :class="['truncate text-xs text-primary-600 font-semibold dark:text-primary-300']">
|
||||
{{ t('stage.chat.reply.replying-to', { name: renderedTarget?.label ?? '' }) }}
|
||||
</span>
|
||||
<span :class="['truncate text-xs text-neutral-500 dark:text-neutral-300']">
|
||||
{{ preview || t('stage.chat.reply.message') }}
|
||||
</span>
|
||||
</div>
|
||||
<IconButton
|
||||
icon="i-solar:close-circle-bold"
|
||||
:class="[
|
||||
'size-6 shrink-0 flex items-center justify-center rounded-md outline-none',
|
||||
'text-neutral-400 transition-colors hover:bg-primary-100 hover:text-neutral-600',
|
||||
'focus-visible:ring-2 focus-visible:ring-primary-400 dark:hover:bg-primary-900 dark:hover:text-neutral-100',
|
||||
]"
|
||||
:aria-label="t('stage.chat.reply.cancel')"
|
||||
:tabindex="target ? 0 : -1"
|
||||
@click="emit('cancel')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChatHistoryReplyPayload } from '../reply'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { getChatReplyPreview } from '../reply'
|
||||
|
||||
const props = defineProps<{
|
||||
target: ChatHistoryReplyPayload
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const preview = computed(() => getChatReplyPreview(props.target))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'mb-1 min-w-0 flex items-center gap-2 rounded-lg px-2 py-1.5 text-left',
|
||||
'bg-black/5 dark:bg-white/8',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
'i-solar:reply-bold-duotone size-3.5 shrink-0',
|
||||
'text-primary-500 dark:text-primary-300',
|
||||
]"
|
||||
/>
|
||||
<div :class="['min-w-0 flex flex-1 flex-col leading-tight']">
|
||||
<span :class="['truncate text-xs text-primary-600 font-semibold dark:text-primary-300']">
|
||||
{{ t('stage.chat.reply.replying-to', { name: target.label }) }}
|
||||
</span>
|
||||
<span :class="['truncate text-xs text-neutral-500 dark:text-neutral-300']">
|
||||
{{ preview || t('stage.chat.reply.message') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChatHistoryItem, ChatMessage } from '../../../../types/chat'
|
||||
import type { ChatHistoryReplyPayload } from '../reply'
|
||||
|
||||
import { isStageCapacitor, isStageWeb } from '@proj-airi/stage-shared'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ChatReplyQuote from './reply-quote.vue'
|
||||
|
||||
import { MarkdownRenderer } from '../../../markdown'
|
||||
import { ChatActionMenu } from '../components/action-menu'
|
||||
import { getChatHistoryItemCopyText } from '../utils'
|
||||
@@ -11,9 +14,12 @@ import { getChatHistoryItemCopyText } from '../utils'
|
||||
const props = withDefaults(defineProps<{
|
||||
message: Extract<ChatMessage, { role: 'user' }>
|
||||
label: string
|
||||
replyTarget?: ChatHistoryReplyPayload
|
||||
canReply?: boolean
|
||||
scrollContainer?: HTMLElement | null
|
||||
variant?: 'desktop' | 'mobile'
|
||||
}>(), {
|
||||
canReply: false,
|
||||
scrollContainer: null,
|
||||
variant: 'desktop',
|
||||
})
|
||||
@@ -21,6 +27,7 @@ const props = withDefaults(defineProps<{
|
||||
const emit = defineEmits<{
|
||||
(e: 'copy'): void
|
||||
(e: 'delete'): void
|
||||
(e: 'reply'): void
|
||||
}>()
|
||||
|
||||
const content = computed(() => {
|
||||
@@ -55,11 +62,14 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat
|
||||
<template>
|
||||
<div v-if="message.role === 'user'" :class="['font-cute', containerClasses]" class="ph-no-capture">
|
||||
<ChatActionMenu
|
||||
:can-reply="canReply"
|
||||
:copy-text="copyText"
|
||||
placement="left"
|
||||
:press-feedback-enabled="variant === 'mobile'"
|
||||
:scroll-container="scrollContainer"
|
||||
@copy="emit('copy')"
|
||||
@delete="emit('delete')"
|
||||
@reply="emit('reply')"
|
||||
>
|
||||
<template #default="{ setMeasuredElement }">
|
||||
<div
|
||||
@@ -72,6 +82,7 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat
|
||||
(isStageWeb() || isStageCapacitor()) && props.variant === 'mobile' ? 'select-none sm:select-auto' : '',
|
||||
]"
|
||||
>
|
||||
<ChatReplyQuote v-if="replyTarget" :target="replyTarget" />
|
||||
<div>
|
||||
<span text-sm text="black/60 dark:white/65" font-normal class="inline <sm:hidden">{{ label }}</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ChatHistoryReplyPayload } from '../reply'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
import { useChatComposer } from './use-chat-composer'
|
||||
|
||||
describe('useChatComposer', () => {
|
||||
it('sends a native reply without changing the draft text', async () => {
|
||||
const activeSessionId = shallowRef('session-1')
|
||||
const send = vi.fn().mockResolvedValue(undefined)
|
||||
const composer = useChatComposer({ activeSessionId, send })
|
||||
const target: ChatHistoryReplyPayload = {
|
||||
label: 'AIRI',
|
||||
message: { id: 'assistant-1', role: 'assistant', content: 'Reply target', slices: [], tool_results: [] },
|
||||
}
|
||||
composer.draft.value = 'My answer'
|
||||
composer.selectReply(target)
|
||||
|
||||
await expect(composer.submit()).resolves.toBe('sent')
|
||||
|
||||
expect(send).toHaveBeenCalledWith({
|
||||
attachments: [],
|
||||
sessionId: 'session-1',
|
||||
replyToMessageId: 'assistant-1',
|
||||
text: 'My answer',
|
||||
})
|
||||
expect(composer.draft.value).toBe('')
|
||||
expect(composer.replyTarget.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('restores a failed send without replacing newer input state', async () => {
|
||||
const activeSessionId = shallowRef('session-1')
|
||||
const firstTarget: ChatHistoryReplyPayload = {
|
||||
label: 'AIRI',
|
||||
message: { id: 'assistant-1', role: 'assistant', content: 'First', slices: [], tool_results: [] },
|
||||
}
|
||||
const secondTarget: ChatHistoryReplyPayload = {
|
||||
label: 'You',
|
||||
message: { id: 'user-2', role: 'user', content: 'Second' },
|
||||
}
|
||||
let composer: ReturnType<typeof useChatComposer>
|
||||
const send = vi.fn(async () => {
|
||||
composer.draft.value = 'New draft'
|
||||
composer.selectReply(secondTarget)
|
||||
throw new Error('Provider failed')
|
||||
})
|
||||
composer = useChatComposer({ activeSessionId, send })
|
||||
composer.draft.value = 'My answer'
|
||||
composer.selectReply(firstTarget)
|
||||
|
||||
await expect(composer.submit()).resolves.toBe('restored')
|
||||
|
||||
expect(composer.draft.value).toBe('My answer\nNew draft')
|
||||
expect(composer.replyTarget.value).toStrictEqual(secondTarget)
|
||||
})
|
||||
|
||||
it('discards a failed snapshot after the active session changes', async () => {
|
||||
const activeSessionId = shallowRef('session-1')
|
||||
const send = vi.fn(async () => {
|
||||
activeSessionId.value = 'session-2'
|
||||
throw new Error('Provider failed')
|
||||
})
|
||||
const composer = useChatComposer<string>({ activeSessionId, send })
|
||||
composer.draft.value = 'Old draft'
|
||||
composer.addAttachments('blob:old')
|
||||
|
||||
await expect(composer.submit()).resolves.toBe('discarded')
|
||||
|
||||
expect(composer.draft.value).toBe('')
|
||||
expect(composer.attachments.value).toEqual([])
|
||||
})
|
||||
|
||||
it('clears only the reply target that matches the deleted message', () => {
|
||||
const target: ChatHistoryReplyPayload = {
|
||||
label: 'AIRI',
|
||||
message: { id: 'assistant-2', role: 'assistant', content: 'Target', slices: [], tool_results: [] },
|
||||
}
|
||||
const composer = useChatComposer({ activeSessionId: shallowRef('session-1'), send: vi.fn() })
|
||||
composer.selectReply(target)
|
||||
|
||||
composer.clearReplyForMessage({ id: 'user-1', role: 'user', content: 'Earlier message' })
|
||||
expect(composer.replyTarget.value).toStrictEqual(target)
|
||||
|
||||
composer.clearReplyForMessage({ id: 'assistant-2', role: 'assistant', content: 'Updated target', slices: [], tool_results: [] })
|
||||
expect(composer.replyTarget.value).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { Ref, ShallowRef } from 'vue'
|
||||
|
||||
import type { ChatHistoryItem } from '../../../../types/chat'
|
||||
import type { ChatHistoryReplyPayload } from '../reply'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { shallowReadonly, shallowRef, watch } from 'vue'
|
||||
|
||||
import { isChatReplyTargetMessage } from '../reply'
|
||||
|
||||
/** One immutable snapshot passed from a composer to the chat domain. */
|
||||
export interface ChatComposerSubmission<TAttachment> {
|
||||
/** Attachments captured when the send starts. */
|
||||
attachments: TAttachment[]
|
||||
/** Session captured when the send starts. */
|
||||
sessionId: string
|
||||
/** Message that the new user turn replies to. */
|
||||
replyToMessageId?: string
|
||||
/** User text without reply presentation text. */
|
||||
text: string
|
||||
}
|
||||
|
||||
/** The observable result of one composer submission attempt. */
|
||||
export type ChatComposerSubmitResult = 'discarded' | 'ignored' | 'restored' | 'sent'
|
||||
|
||||
/** Dependencies and runtime ownership for one local composer. */
|
||||
export interface UseChatComposerOptions<TAttachment> {
|
||||
/** The session selection for this window or view. */
|
||||
activeSessionId: Readonly<Ref<string>>
|
||||
/** Sends one composer snapshot through the chat domain. */
|
||||
send: (submission: ChatComposerSubmission<TAttachment>) => Promise<unknown>
|
||||
}
|
||||
|
||||
/** Controls the transient state and submission lifecycle of one chat input. */
|
||||
export interface ChatComposerController<TAttachment> {
|
||||
/** Attachments currently shown in the composer. */
|
||||
attachments: ShallowRef<TAttachment[]>
|
||||
/** User text currently shown in the composer. */
|
||||
draft: ShallowRef<string>
|
||||
/** Whether an input method editor is composing text. */
|
||||
isComposing: ShallowRef<boolean>
|
||||
/** Message currently selected as the reply target. */
|
||||
replyTarget: Readonly<Ref<ChatHistoryReplyPayload | undefined>>
|
||||
/** Adds attachments to the current draft. */
|
||||
addAttachments: (...attachments: TAttachment[]) => void
|
||||
/** Clears the current reply target. */
|
||||
clearReply: () => void
|
||||
/** Clears the reply target when the deleted message owns it. */
|
||||
clearReplyForMessage: (message: ChatHistoryItem) => void
|
||||
/** Removes one attachment. */
|
||||
removeAttachment: (index: number) => void
|
||||
/** Selects a message as the reply target. */
|
||||
selectReply: (target: ChatHistoryReplyPayload) => void
|
||||
/** Submits one snapshot and restores it after a recoverable failure. */
|
||||
submit: () => Promise<ChatComposerSubmitResult>
|
||||
}
|
||||
|
||||
function isCancelledSessionSend(error: unknown): boolean {
|
||||
const message = errorMessageFrom(error) ?? String(error)
|
||||
return message.includes('Chat session was reset before send could start')
|
||||
|| message.includes('Chat session was removed before send completed')
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns transient chat input state and one optimistic send transaction.
|
||||
*
|
||||
* Each chat surface creates one controller. The controller does not synchronize
|
||||
* drafts or attachments across Electron renderers.
|
||||
*/
|
||||
export function useChatComposer<TAttachment = never>(options: UseChatComposerOptions<TAttachment>): ChatComposerController<TAttachment> {
|
||||
const attachments = shallowRef<TAttachment[]>([])
|
||||
const draft = shallowRef('')
|
||||
const isComposing = shallowRef(false)
|
||||
const replyTarget = shallowRef<ChatHistoryReplyPayload>()
|
||||
|
||||
function addAttachments(...nextAttachments: TAttachment[]) {
|
||||
attachments.value = [...attachments.value, ...nextAttachments]
|
||||
}
|
||||
|
||||
function clearReply() {
|
||||
replyTarget.value = undefined
|
||||
}
|
||||
|
||||
function clearReplyForMessage(message: ChatHistoryItem) {
|
||||
if (isChatReplyTargetMessage(replyTarget.value, message))
|
||||
clearReply()
|
||||
}
|
||||
|
||||
function removeAttachment(index: number) {
|
||||
const attachment = attachments.value[index]
|
||||
if (!attachment)
|
||||
return
|
||||
|
||||
attachments.value = attachments.value.filter((_, attachmentIndex) => attachmentIndex !== index)
|
||||
}
|
||||
|
||||
function selectReply(target: ChatHistoryReplyPayload) {
|
||||
replyTarget.value = target
|
||||
}
|
||||
|
||||
async function submit(): Promise<ChatComposerSubmitResult> {
|
||||
if (isComposing.value || (!draft.value.trim() && attachments.value.length === 0))
|
||||
return 'ignored'
|
||||
|
||||
const submission: ChatComposerSubmission<TAttachment> = {
|
||||
attachments: [...attachments.value],
|
||||
sessionId: options.activeSessionId.value,
|
||||
replyToMessageId: replyTarget.value?.message.id,
|
||||
text: draft.value,
|
||||
}
|
||||
const submittedDraft = draft.value
|
||||
const submittedReply = replyTarget.value
|
||||
|
||||
attachments.value = []
|
||||
draft.value = ''
|
||||
replyTarget.value = undefined
|
||||
|
||||
try {
|
||||
await options.send(submission)
|
||||
return 'sent'
|
||||
}
|
||||
catch (error) {
|
||||
const canRestore = !isCancelledSessionSend(error)
|
||||
&& options.activeSessionId.value === submission.sessionId
|
||||
|
||||
if (!canRestore) {
|
||||
return 'discarded'
|
||||
}
|
||||
|
||||
attachments.value = [...submission.attachments, ...attachments.value]
|
||||
draft.value = draft.value ? `${submittedDraft}\n${draft.value}` : submittedDraft
|
||||
if (!replyTarget.value)
|
||||
replyTarget.value = submittedReply
|
||||
return 'restored'
|
||||
}
|
||||
}
|
||||
|
||||
watch(options.activeSessionId, clearReply)
|
||||
|
||||
return {
|
||||
attachments,
|
||||
draft,
|
||||
isComposing,
|
||||
replyTarget: shallowReadonly(replyTarget),
|
||||
addAttachments,
|
||||
clearReply,
|
||||
clearReplyForMessage,
|
||||
removeAttachment,
|
||||
selectReply,
|
||||
submit,
|
||||
}
|
||||
}
|
||||
+49
@@ -37,10 +37,12 @@ function startScrollBehavior({
|
||||
container,
|
||||
messages,
|
||||
scrollToIndex,
|
||||
tailInset = shallowRef(0),
|
||||
}: {
|
||||
container: ShallowRef<HTMLElement | null>
|
||||
messages: ShallowRef<TestMessage[]>
|
||||
scrollToIndex: (index: number, align: 'start' | 'end') => void
|
||||
tailInset?: ShallowRef<number>
|
||||
}) {
|
||||
const scope = effectScope()
|
||||
activeScopes.push(scope)
|
||||
@@ -50,6 +52,7 @@ function startScrollBehavior({
|
||||
messages,
|
||||
getKey: message => message.id,
|
||||
scrollToIndex,
|
||||
tailInset,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -146,6 +149,52 @@ describe('useChatHistoryScroll', () => {
|
||||
expect(scrollToIndex).toHaveBeenCalledWith(2, 'end')
|
||||
})
|
||||
|
||||
// https://github.com/moeru-ai/airi/pull/2489#discussion_r3968140754
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// The floating composer inset was sampled only when another change requested
|
||||
// a scroll. Expanding a reply preview or attachment area changed the available
|
||||
// tail space without moving the last message above the composer.
|
||||
//
|
||||
// An inset change must request end alignment while the reader follows the tail.
|
||||
it('realigns the followed tail after the composer inset changes', async () => {
|
||||
const currentContainer = createScrollContainer(2)
|
||||
currentContainer.scrollTop = currentContainer.scrollHeight
|
||||
const container = shallowRef<HTMLElement | null>(currentContainer)
|
||||
const messages = shallowRef<TestMessage[]>([{ id: 'user-1' }, { id: 'assistant-1' }])
|
||||
const scrollToIndex = vi.fn()
|
||||
const tailInset = shallowRef(80)
|
||||
startScrollBehavior({ container, messages, scrollToIndex, tailInset })
|
||||
await flushReactivity()
|
||||
scrollToIndex.mockClear()
|
||||
|
||||
tailInset.value = 144
|
||||
await flushReactivity()
|
||||
|
||||
expect(scrollToIndex).toHaveBeenCalledTimes(1)
|
||||
expect(scrollToIndex).toHaveBeenCalledWith(1, 'end')
|
||||
})
|
||||
|
||||
it('keeps the reader position after the composer inset changes away from the tail', async () => {
|
||||
const currentContainer = createScrollContainer(2)
|
||||
currentContainer.scrollTop = currentContainer.scrollHeight
|
||||
const container = shallowRef<HTMLElement | null>(currentContainer)
|
||||
const messages = shallowRef<TestMessage[]>([{ id: 'user-1' }, { id: 'assistant-1' }])
|
||||
const scrollToIndex = vi.fn()
|
||||
const tailInset = shallowRef(80)
|
||||
startScrollBehavior({ container, messages, scrollToIndex, tailInset })
|
||||
await flushReactivity()
|
||||
scrollToIndex.mockClear()
|
||||
|
||||
currentContainer.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: -100 }))
|
||||
currentContainer.scrollTop = 0
|
||||
currentContainer.dispatchEvent(new Event('scroll'))
|
||||
tailInset.value = 144
|
||||
await flushReactivity()
|
||||
|
||||
expect(scrollToIndex).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops following after a user scroll moves the viewport from the tail', async () => {
|
||||
const currentContainer = createScrollContainer(2)
|
||||
currentContainer.scrollTop = currentContainer.scrollHeight
|
||||
|
||||
+4
-1
@@ -8,6 +8,8 @@ interface ChatHistoryScrollOptions<TMessage> {
|
||||
messages: Readonly<Ref<TMessage[]>>
|
||||
getKey: (message: TMessage, index: number) => string | number
|
||||
scrollToIndex: (index: number, align: 'start' | 'end') => void
|
||||
/** Space that a floating composer covers at the end of the viewport. */
|
||||
tailInset: Readonly<Ref<number>>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,6 +24,7 @@ export function useChatHistoryScroll<TMessage>({
|
||||
messages,
|
||||
getKey,
|
||||
scrollToIndex,
|
||||
tailInset,
|
||||
}: ChatHistoryScrollOptions<TMessage>) {
|
||||
let didRequestInitialScroll = false
|
||||
let hasUserScrollIntent = false
|
||||
@@ -101,7 +104,7 @@ export function useChatHistoryScroll<TMessage>({
|
||||
})
|
||||
|
||||
watch(
|
||||
[container, messages],
|
||||
[container, messages, tailInset],
|
||||
([currentContainer, currentMessages]) => {
|
||||
if (currentContainer !== previousContainer) {
|
||||
previousContainer = currentContainer
|
||||
|
||||
+15
-3
@@ -9,6 +9,13 @@ interface VirtualScrollRequest {
|
||||
index: number
|
||||
}
|
||||
|
||||
interface VirtualizerScrollOptions {
|
||||
/** Extra space reserved after an end-aligned item. */
|
||||
tailInset: Readonly<Ref<number>>
|
||||
/** Virtua handle that owns item measurement and scrolling. */
|
||||
virtualizer: Readonly<ShallowRef<VirtualizerHandle | null>>
|
||||
}
|
||||
|
||||
interface VirtualizerBottomAlignmentOptions {
|
||||
container: Readonly<Ref<HTMLElement | null>>
|
||||
itemCount: Readonly<Ref<number>>
|
||||
@@ -22,7 +29,7 @@ interface VirtualizerBottomAlignmentOptions {
|
||||
* a non-zero viewport size. This adapter polls only while one request waits for that value.
|
||||
*/
|
||||
export function useVirtualizerScroll(
|
||||
virtualizer: Readonly<ShallowRef<VirtualizerHandle | null>>,
|
||||
{ tailInset, virtualizer }: VirtualizerScrollOptions,
|
||||
) {
|
||||
let didObserveReadyFrame = false
|
||||
const pendingRequest = shallowRef<VirtualScrollRequest>()
|
||||
@@ -46,7 +53,10 @@ export function useVirtualizerScroll(
|
||||
return
|
||||
}
|
||||
|
||||
currentVirtualizer.scrollToIndex(request.index, { align: request.align })
|
||||
currentVirtualizer.scrollToIndex(request.index, {
|
||||
align: request.align,
|
||||
offset: request.align === 'end' ? Math.max(0, tailInset.value) : 0,
|
||||
})
|
||||
pendingRequest.value = undefined
|
||||
didObserveReadyFrame = false
|
||||
pause()
|
||||
@@ -106,7 +116,9 @@ export function useVirtualizerBottomAlignment({
|
||||
return {
|
||||
itemProps: () => ({
|
||||
style: {
|
||||
transform: bottomOffset.value > 0 ? `translateY(${bottomOffset.value}px)` : undefined,
|
||||
transform: bottomOffset.value > 0
|
||||
? `translateY(max(0px, calc(${bottomOffset.value}px - var(--chat-history-bottom-inset, 0px))))`
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -2,9 +2,14 @@ export { ChatActionMenu } from './components/action-menu'
|
||||
export { default as ChatAssistantItem } from './components/assistant-item.vue'
|
||||
export { default as ChatErrorItem } from './components/error-item.vue'
|
||||
export { default as ChatHistory } from './components/history.vue'
|
||||
export { default as ChatReplyPreview } from './components/reply-preview.vue'
|
||||
export { default as ChatSessionsDrawer } from './components/sessions-drawer.vue'
|
||||
export { createToolResultError, normalizeToolResultText } from './components/tool-call-display'
|
||||
export type { ChatToolCallRendererProps, ChatToolCallRendererRegistry } from './components/tool-call-renderer'
|
||||
export { default as ChatToolCallShell } from './components/tool-call-shell.vue'
|
||||
export { default as ChatUserItem } from './components/user-item.vue'
|
||||
export { useChatComposer } from './composables/use-chat-composer'
|
||||
export type { ChatComposerController, ChatComposerSubmission, ChatComposerSubmitResult, UseChatComposerOptions } from './composables/use-chat-composer'
|
||||
export { default as JournalPreviewModal } from './JournalPreviewModal.vue'
|
||||
export { getChatReplyPreview, isChatReplyTargetMessage, normalizeChatReplyPreview } from './reply'
|
||||
export type { ChatHistoryReplyPayload } from './reply'
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ChatHistoryReplyPayload } from './reply'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isChatReplyTargetMessage, normalizeChatReplyPreview } from './reply'
|
||||
|
||||
describe('chat reply composition', () => {
|
||||
it('normalizes a multi-line message for the compact reply preview', () => {
|
||||
expect(normalizeChatReplyPreview(' First line\n\nSecond line ')).toBe('First line Second line')
|
||||
})
|
||||
|
||||
it('matches reply targets by message id and object identity', () => {
|
||||
const message: ChatHistoryReplyPayload['message'] = { role: 'user', content: 'hello' }
|
||||
const target: ChatHistoryReplyPayload = { label: 'You', message }
|
||||
|
||||
expect(isChatReplyTargetMessage(target, message)).toBe(true)
|
||||
expect(isChatReplyTargetMessage(target, { role: 'user', content: 'hello' })).toBe(false)
|
||||
|
||||
const identifiedTarget: ChatHistoryReplyPayload = {
|
||||
label: 'AIRI',
|
||||
message: { id: 'assistant-1', role: 'assistant', content: 'hello', slices: [], tool_results: [] },
|
||||
}
|
||||
expect(isChatReplyTargetMessage(identifiedTarget, {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'updated',
|
||||
slices: [],
|
||||
tool_results: [],
|
||||
})).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ChatHistoryItem } from '../../../types/chat'
|
||||
|
||||
import { getChatHistoryItemCopyText } from './utils'
|
||||
|
||||
/** Keeps the composer preview readable without changing the selected message. */
|
||||
const REPLY_PREVIEW_CHARACTER_LIMIT = 160
|
||||
|
||||
/** Identifies the chat message selected by a reply action. */
|
||||
export interface ChatHistoryReplyPayload {
|
||||
/** Visible author name at the time that the user selects the message. */
|
||||
label: string
|
||||
/** Selected message used to build the preview and the outgoing quote. */
|
||||
message: ChatHistoryItem
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes message text for the compact reply preview.
|
||||
*
|
||||
* @example
|
||||
* normalizeChatReplyPreview('First line\n\nSecond line')
|
||||
* // => 'First line Second line'
|
||||
*/
|
||||
export function normalizeChatReplyPreview(text: string): string {
|
||||
const normalized = text.replace(/\s+/g, ' ').trim()
|
||||
if (normalized.length <= REPLY_PREVIEW_CHARACTER_LIMIT)
|
||||
return normalized
|
||||
|
||||
return `${normalized.slice(0, REPLY_PREVIEW_CHARACTER_LIMIT - 1).trimEnd()}…`
|
||||
}
|
||||
|
||||
/** Returns the text shown in the composer for a selected reply target. */
|
||||
export function getChatReplyPreview(target: ChatHistoryReplyPayload): string {
|
||||
return normalizeChatReplyPreview(getChatHistoryItemCopyText(target.message))
|
||||
}
|
||||
|
||||
/** Returns true when a history message is the selected reply target. */
|
||||
export function isChatReplyTargetMessage(target: ChatHistoryReplyPayload | undefined, message: ChatHistoryItem): boolean {
|
||||
if (!target)
|
||||
return false
|
||||
|
||||
if (target.message.id != null || message.id != null)
|
||||
return target.message.id != null && target.message.id === message.id
|
||||
|
||||
return target.message === message
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export interface ChatSendOutboxEntry {
|
||||
cloudChatId?: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
replyToMessageId?: string
|
||||
attempts: number
|
||||
lastError?: string
|
||||
queuedAt: number
|
||||
|
||||
@@ -80,6 +80,22 @@ describe('isCloudSyncableMessage', () => {
|
||||
})
|
||||
|
||||
describe('wireMessageToLocal', () => {
|
||||
it('preserves a native reply relation from the wire message', () => {
|
||||
const local = wireMessageToLocal(makeWire({
|
||||
id: 'user-reply',
|
||||
role: 'user',
|
||||
content: 'My follow-up',
|
||||
replyToMessageId: 'assistant-1',
|
||||
seq: 2,
|
||||
}))
|
||||
|
||||
expect(local).toMatchObject({
|
||||
id: 'user-reply',
|
||||
content: 'My follow-up',
|
||||
replyToMessageId: 'assistant-1',
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* Server pushes an assistant wire message; local shape needs slices and
|
||||
|
||||
@@ -104,6 +104,7 @@ export function wireMessageToLocal(wire: WireMessage): ChatHistoryItem {
|
||||
return Object.assign(assistant, {
|
||||
id: wire.id,
|
||||
createdAt: wire.createdAt,
|
||||
...(wire.replyToMessageId ? { replyToMessageId: wire.replyToMessageId } : {}),
|
||||
})
|
||||
}
|
||||
case 'user':
|
||||
@@ -112,6 +113,7 @@ export function wireMessageToLocal(wire: WireMessage): ChatHistoryItem {
|
||||
content: wire.content,
|
||||
id: wire.id,
|
||||
createdAt: wire.createdAt,
|
||||
...(wire.replyToMessageId ? { replyToMessageId: wire.replyToMessageId } : {}),
|
||||
}
|
||||
case 'system':
|
||||
return {
|
||||
@@ -119,6 +121,7 @@ export function wireMessageToLocal(wire: WireMessage): ChatHistoryItem {
|
||||
content: wire.content,
|
||||
id: wire.id,
|
||||
createdAt: wire.createdAt,
|
||||
...(wire.replyToMessageId ? { replyToMessageId: wire.replyToMessageId } : {}),
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
@@ -126,6 +129,7 @@ export function wireMessageToLocal(wire: WireMessage): ChatHistoryItem {
|
||||
content: wire.content,
|
||||
id: wire.id,
|
||||
createdAt: wire.createdAt,
|
||||
...(wire.replyToMessageId ? { replyToMessageId: wire.replyToMessageId } : {}),
|
||||
}
|
||||
case 'tool':
|
||||
// Tool messages require a `tool_call_id` we cannot reconstruct from
|
||||
@@ -136,6 +140,7 @@ export function wireMessageToLocal(wire: WireMessage): ChatHistoryItem {
|
||||
content: wire.content || '[tool message: cannot reconstruct without tool_call_id]',
|
||||
id: wire.id,
|
||||
createdAt: wire.createdAt,
|
||||
...(wire.replyToMessageId ? { replyToMessageId: wire.replyToMessageId } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,6 +374,28 @@ describe('chat store contract', () => {
|
||||
await settings.setReasoning(false)
|
||||
})
|
||||
|
||||
it('passes a native reply relation to the chat runtime', async () => {
|
||||
sessionMessages['session-1'] = [
|
||||
{ role: 'system', content: 'system prompt', createdAt: 1, id: 'system' },
|
||||
{ role: 'assistant', content: 'Earlier answer', slices: [], tool_results: [], id: 'assistant-1' },
|
||||
]
|
||||
llmStreamMock.mockImplementationOnce(async (_model: string, _chatProvider: ChatProvider, _messages: Message[], options: StreamOptions) => {
|
||||
await options.onStreamEvent?.({ type: 'finish', finishReason: 'stop' })
|
||||
})
|
||||
const store = useChatStore()
|
||||
|
||||
await store.send({
|
||||
sessionId: 'session-1',
|
||||
text: 'My follow-up',
|
||||
replyToMessageId: 'assistant-1',
|
||||
})
|
||||
|
||||
expect(sessionMessages['session-1']?.find(message => message.role === 'user')).toMatchObject({
|
||||
content: 'My follow-up',
|
||||
replyToMessageId: 'assistant-1',
|
||||
})
|
||||
})
|
||||
|
||||
// https://github.com/moeru-ai/airi/issues/2085
|
||||
it('hydrates the target session before sending for Issue #2085', async () => {
|
||||
// ROOT CAUSE:
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface ChatSendPayload {
|
||||
input?: WebSocketEventInputs
|
||||
/** Session that owns the new turn. */
|
||||
sessionId: string
|
||||
/** Message that the new user turn replies to in the target session. */
|
||||
replyToMessageId?: string
|
||||
/** User text for the new turn. */
|
||||
text: string
|
||||
/** Request-specific tools selected by their model-facing names. */
|
||||
@@ -339,6 +341,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
id: message.id,
|
||||
role: 'user',
|
||||
content: messageText,
|
||||
replyToMessageId: message.replyToMessageId,
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -414,6 +417,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
chatProvider,
|
||||
attachments: payload.attachments,
|
||||
input: payload.input,
|
||||
replyToMessageId: payload.replyToMessageId,
|
||||
toolReferences: payload.tools,
|
||||
temperature: payload.temperature ?? consciousnessStore.activeTemperature,
|
||||
topP: payload.topP ?? consciousnessStore.activeTopP,
|
||||
@@ -469,6 +473,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
return await executeSend({
|
||||
sessionId: payload.sessionId,
|
||||
text,
|
||||
replyToMessageId: sourceMessage?.replyToMessageId,
|
||||
tools: payload.tools ?? sourceMessage?.tools,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -878,6 +878,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
||||
cloudChatId: result.cloudChatId,
|
||||
role: message.role as CloudSyncableRole,
|
||||
content: text,
|
||||
replyToMessageId: message.replyToMessageId,
|
||||
attempts: 0,
|
||||
queuedAt: Date.now(),
|
||||
}))
|
||||
@@ -1130,7 +1131,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
||||
* transparently. UI consumers can watch `outboxPendingCount` to
|
||||
* surface "X syncing".
|
||||
*/
|
||||
async function pushMessageToCloud(sessionId: string, message: { id: string, role: CloudSyncableRole, content: string }) {
|
||||
async function pushMessageToCloud(sessionId: string, message: { id: string, role: CloudSyncableRole, content: string, replyToMessageId?: string }) {
|
||||
const userId = getCurrentUserId()
|
||||
if (userId === 'local')
|
||||
return
|
||||
@@ -1141,6 +1142,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
||||
cloudChatId: sessionMetas.value[sessionId]?.cloudChatId,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
replyToMessageId: message.replyToMessageId,
|
||||
attempts: 0,
|
||||
queuedAt: Date.now(),
|
||||
}
|
||||
@@ -1157,7 +1159,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
||||
try {
|
||||
await wsClient.sendMessages({
|
||||
chatId: entry.cloudChatId,
|
||||
messages: [{ id: entry.messageId, role: entry.role, content: entry.content }],
|
||||
messages: [{ id: entry.messageId, role: entry.role, content: entry.content, replyToMessageId: entry.replyToMessageId }],
|
||||
})
|
||||
await enqueuePersist(() => chatSessionsRepo.dequeueOutbox(userId, [entry.messageId]))
|
||||
await refreshOutboxPendingCount()
|
||||
@@ -1228,7 +1230,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
||||
try {
|
||||
await wsClient.sendMessages({
|
||||
chatId: cloudChatId,
|
||||
messages: sessionEntries.map(e => ({ id: e.messageId, role: e.role, content: e.content })),
|
||||
messages: sessionEntries.map(e => ({ id: e.messageId, role: e.role, content: e.content, replyToMessageId: e.replyToMessageId })),
|
||||
})
|
||||
succeededIds.push(...sessionEntries.map(e => e.messageId))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user