feat(stage-ui): add swipe to reply for chat messages (#2489)

This commit is contained in:
Neko
2026-09-10 00:01:53 +08:00
committed by GitHub
parent 3e94ea816c
commit 0d7b5e9a60
57 changed files with 3649 additions and 320 deletions
@@ -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)
+2
View File
@@ -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[]
}
+6
View File
@@ -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,
+1
View File
@@ -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'
@@ -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',
]"
@@ -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"
@@ -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>
@@ -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,
}
}
@@ -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
@@ -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
@@ -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:
+5
View File
@@ -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))
}