mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
* fix(mobile): preserve multi-image chat attachments * fix(mobile): use preferred array syntax * fix(mobile): harden multi-image attachment flow * fix(mobile): retain first-send image previews
805 lines
24 KiB
TypeScript
805 lines
24 KiB
TypeScript
import { createElement } from 'react'
|
|
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
|
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
|
|
|
|
type DraftState = ReturnType<typeof useMobileNativeChatDrafts>
|
|
|
|
function userTextMessage(id: string, text: string): NativeChatMessage {
|
|
return {
|
|
id,
|
|
role: 'user',
|
|
blocks: [{ type: 'text', text }],
|
|
timestamp: null,
|
|
source: 'transcript'
|
|
}
|
|
}
|
|
|
|
function assistantTextMessage(id: string, text: string): NativeChatMessage {
|
|
return {
|
|
id,
|
|
role: 'assistant',
|
|
blocks: [{ type: 'text', text }],
|
|
timestamp: null,
|
|
source: 'transcript'
|
|
}
|
|
}
|
|
|
|
describe('useMobileNativeChatDrafts', () => {
|
|
let renderer: ReactTestRenderer | null = null
|
|
let state: DraftState | null = null
|
|
|
|
beforeEach(() => {
|
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
|
})
|
|
|
|
afterEach(() => {
|
|
act(() => renderer?.unmount())
|
|
renderer = null
|
|
state = null
|
|
})
|
|
|
|
function Harness({
|
|
tabId,
|
|
sessionId = `session-${tabId}`,
|
|
messages = [],
|
|
launchDraft = null,
|
|
chatActive = true,
|
|
transcriptLoading = false
|
|
}: {
|
|
tabId: string
|
|
sessionId?: string | null
|
|
messages?: NativeChatMessage[]
|
|
launchDraft?: string | null
|
|
chatActive?: boolean
|
|
transcriptLoading?: boolean
|
|
}): null {
|
|
state = useMobileNativeChatDrafts({
|
|
hostId: 'host',
|
|
worktreeId: 'worktree',
|
|
tabId,
|
|
sessionId,
|
|
messages,
|
|
launchDraft,
|
|
chatActive,
|
|
transcriptLoading
|
|
})
|
|
return null
|
|
}
|
|
|
|
async function mount(tabId: string): Promise<void> {
|
|
const original = console.error
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
|
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
|
return
|
|
}
|
|
original(...args)
|
|
})
|
|
try {
|
|
await act(async () => {
|
|
renderer = create(createElement(Harness, { tabId }))
|
|
})
|
|
} finally {
|
|
consoleSpy.mockRestore()
|
|
}
|
|
}
|
|
|
|
async function switchTo(tabId: string): Promise<void> {
|
|
await act(async () => renderer?.update(createElement(Harness, { tabId })))
|
|
}
|
|
|
|
it('keeps drafts and accepted pending messages on their originating tabs', async () => {
|
|
await mount('a')
|
|
act(() => state?.setComposerText('from a'))
|
|
const originA = state?.captureSendOrigin('from a')
|
|
expect(originA).not.toBeNull()
|
|
act(() => {
|
|
if (originA) {
|
|
state?.clearDraftForSend(originA, 'from a')
|
|
}
|
|
})
|
|
|
|
await switchTo('b')
|
|
act(() => state?.setComposerText('from b'))
|
|
act(() => {
|
|
if (originA) {
|
|
state?.acceptSend(originA, 'from a')
|
|
}
|
|
})
|
|
expect(state?.composerText).toBe('from b')
|
|
expect(state?.pending).toEqual([])
|
|
|
|
await switchTo('a')
|
|
expect(state?.composerText).toBe('')
|
|
expect(state?.pending.map((pending) => pending.text)).toEqual(['from a'])
|
|
})
|
|
|
|
it('clears the composer at send time, before the RPC settles', async () => {
|
|
await mount('a')
|
|
act(() => state?.setComposerText('ping'))
|
|
const origin = state?.captureSendOrigin('ping')
|
|
act(() => {
|
|
if (origin) {
|
|
state?.clearDraftForSend(origin, 'ping')
|
|
}
|
|
})
|
|
expect(state?.composerText).toBe('')
|
|
})
|
|
|
|
it('restores the text on a definite rejection', async () => {
|
|
await mount('a')
|
|
act(() => state?.setComposerText('ping'))
|
|
const origin = state?.captureSendOrigin('ping')
|
|
act(() => {
|
|
if (origin) {
|
|
state?.clearDraftForSend(origin, 'ping')
|
|
state?.restoreRejectedDraft(origin, 'ping')
|
|
}
|
|
})
|
|
expect(state?.composerText).toBe('ping')
|
|
})
|
|
|
|
it('does not clobber newer edits when restoring a rejected send', async () => {
|
|
await mount('a')
|
|
act(() => state?.setComposerText('ping'))
|
|
const origin = state?.captureSendOrigin('ping')
|
|
act(() => {
|
|
if (origin) {
|
|
state?.clearDraftForSend(origin, 'ping')
|
|
}
|
|
})
|
|
act(() => state?.setComposerText('newer edit'))
|
|
act(() => {
|
|
if (origin) {
|
|
state?.restoreRejectedDraft(origin, 'ping')
|
|
}
|
|
})
|
|
expect(state?.composerText).toBe('newer edit')
|
|
})
|
|
|
|
it('restores a rejected send onto its originating tab only', async () => {
|
|
await mount('a')
|
|
act(() => state?.setComposerText('from a'))
|
|
const originA = state?.captureSendOrigin('from a')
|
|
act(() => {
|
|
if (originA) {
|
|
state?.clearDraftForSend(originA, 'from a')
|
|
}
|
|
})
|
|
|
|
await switchTo('b')
|
|
act(() => {
|
|
if (originA) {
|
|
state?.restoreRejectedDraft(originA, 'from a')
|
|
}
|
|
})
|
|
expect(state?.composerText).toBe('')
|
|
|
|
await switchTo('a')
|
|
expect(state?.composerText).toBe('from a')
|
|
})
|
|
|
|
it('keeps the composer clear when the echo lands after the unconfirmed deadline', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
await mount('a')
|
|
act(() => state?.setComposerText('ping'))
|
|
const origin = state?.captureSendOrigin('ping')
|
|
act(() => {
|
|
if (origin) {
|
|
state?.clearDraftForSend(origin, 'ping')
|
|
state?.holdUnconfirmedSend(origin, 'ping', vi.fn())
|
|
}
|
|
})
|
|
expect(state?.composerText).toBe('')
|
|
|
|
// A relay drop can stall the transcript stream past the deadline; the
|
|
// delivered prompt must not reappear in the composer when it recovers.
|
|
act(() => vi.advanceTimersByTime(25_000))
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, { tabId: 'a', messages: [userTextMessage('m1', 'ping')] })
|
|
)
|
|
)
|
|
expect(state?.composerText).toBe('')
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('clears one pending per landed message so duplicate sends are not all dropped', async () => {
|
|
await mount('a')
|
|
const origin = state?.captureSendOrigin('ping')
|
|
act(() => {
|
|
if (origin) {
|
|
state?.acceptSend(origin, 'ping')
|
|
state?.acceptSend(origin, 'ping')
|
|
}
|
|
})
|
|
expect(state?.pending.map((pending) => pending.text)).toEqual(['ping', 'ping'])
|
|
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, { tabId: 'a', messages: [userTextMessage('m1', 'ping')] })
|
|
)
|
|
)
|
|
expect(state?.pending.map((pending) => pending.text)).toEqual(['ping'])
|
|
})
|
|
|
|
it('keeps an image-only echo through an agent reply, clearing only when the user turn lands', async () => {
|
|
await mount('a')
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] })
|
|
)
|
|
)
|
|
const origin = state?.captureSendOrigin('')
|
|
act(() => {
|
|
if (origin) {
|
|
state?.acceptSend(origin, '', ['file:///a.jpg'])
|
|
}
|
|
})
|
|
// The echo carries the preview thumbnail and has no text to match against.
|
|
expect(state?.pending.map((pending) => pending.images)).toEqual([['file:///a.jpg']])
|
|
|
|
// An agent reply grows the transcript but must NOT clear the photo echo early.
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, {
|
|
tabId: 'a',
|
|
messages: [assistantTextMessage('a1', 'hi'), assistantTextMessage('a2', 'nice photo')]
|
|
})
|
|
)
|
|
)
|
|
expect(state?.pending.map((pending) => pending.images)).toEqual([['file:///a.jpg']])
|
|
|
|
// The user's own image echo landing (Claude records it as an
|
|
// `[Image: source: …]` turn) clears it.
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, {
|
|
tabId: 'a',
|
|
messages: [
|
|
assistantTextMessage('a1', 'hi'),
|
|
assistantTextMessage('a2', 'nice photo'),
|
|
userTextMessage('u1', '[Image: source: /tmp/a.png]')
|
|
]
|
|
})
|
|
)
|
|
)
|
|
expect(state?.pending).toEqual([])
|
|
expect(state?.imagePreviewsByMessageId).toEqual({ u1: ['file:///a.jpg'] })
|
|
})
|
|
|
|
it("keeps an image-only echo when an unrelated text send's echo lands", async () => {
|
|
await mount('a')
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] })
|
|
)
|
|
)
|
|
const textOrigin = state?.captureSendOrigin('ping')
|
|
const imageOrigin = state?.captureSendOrigin('')
|
|
act(() => {
|
|
if (textOrigin && imageOrigin) {
|
|
state?.acceptSend(textOrigin, 'ping')
|
|
state?.acceptSend(imageOrigin, '', ['file:///a.jpg'])
|
|
}
|
|
})
|
|
expect(state?.pending).toHaveLength(2)
|
|
|
|
// The text echo lands first: it must clear only the text pending — a user
|
|
// turn that is not an image echo cannot reconcile the photo.
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, {
|
|
tabId: 'a',
|
|
messages: [assistantTextMessage('a1', 'hi'), userTextMessage('u1', 'ping')]
|
|
})
|
|
)
|
|
)
|
|
expect(state?.pending.map((pending) => pending.images)).toEqual([['file:///a.jpg']])
|
|
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, {
|
|
tabId: 'a',
|
|
messages: [
|
|
assistantTextMessage('a1', 'hi'),
|
|
userTextMessage('u1', 'ping'),
|
|
userTextMessage('u2', '[Image: source: /tmp/a.png]')
|
|
]
|
|
})
|
|
)
|
|
)
|
|
expect(state?.pending).toEqual([])
|
|
})
|
|
|
|
it('reconciles a captioned image echo that carries the [Image #N] marker', async () => {
|
|
await mount('a')
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] })
|
|
)
|
|
)
|
|
const origin = state?.captureSendOrigin('look at this')
|
|
act(() => {
|
|
if (origin) {
|
|
state?.acceptSend(origin, 'look at this', ['file:///a.jpg'])
|
|
}
|
|
})
|
|
expect(state?.pending).toHaveLength(1)
|
|
|
|
// Claude echoes a captioned image send as two turns: the source marker and
|
|
// the caption prefixed with `[Image #1] ` — the pending must still match.
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, {
|
|
tabId: 'a',
|
|
messages: [
|
|
assistantTextMessage('a1', 'hi'),
|
|
userTextMessage('u1', '[Image: source: /tmp/a.png]'),
|
|
userTextMessage('u2', '[Image #1] look at this')
|
|
]
|
|
})
|
|
)
|
|
)
|
|
expect(state?.pending).toEqual([])
|
|
expect(state?.imagePreviewsByMessageId).toEqual({ u2: ['file:///a.jpg'] })
|
|
})
|
|
|
|
it('hands a marker-only image preview to the authoritative user bubble', async () => {
|
|
await mount('a')
|
|
const origin = state?.captureSendOrigin('')
|
|
act(() => {
|
|
if (origin) {
|
|
state?.acceptSend(origin, '', ['file:///a.jpg'])
|
|
}
|
|
})
|
|
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, {
|
|
tabId: 'a',
|
|
messages: [userTextMessage('u1', '[Image #1]')]
|
|
})
|
|
)
|
|
)
|
|
|
|
expect(state?.pending).toEqual([])
|
|
expect(state?.imagePreviewsByMessageId).toEqual({ u1: ['file:///a.jpg'] })
|
|
})
|
|
|
|
it('does not reconcile a repeated send against an older identical turn', async () => {
|
|
await mount('a')
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, { tabId: 'a', messages: [userTextMessage('old', 'ping')] })
|
|
)
|
|
)
|
|
const origin = state?.captureSendOrigin('ping')
|
|
act(() => {
|
|
if (origin) {
|
|
state?.acceptSend(origin, 'ping')
|
|
}
|
|
})
|
|
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, {
|
|
tabId: 'a',
|
|
messages: [userTextMessage('old', 'ping'), assistantTextMessage('other', 'working')]
|
|
})
|
|
)
|
|
)
|
|
expect(state?.pending.map((pending) => pending.text)).toEqual(['ping'])
|
|
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, {
|
|
tabId: 'a',
|
|
messages: [
|
|
userTextMessage('old', 'ping'),
|
|
assistantTextMessage('other', 'working'),
|
|
userTextMessage('new', 'ping')
|
|
]
|
|
})
|
|
)
|
|
)
|
|
expect(state?.pending).toEqual([])
|
|
})
|
|
|
|
it('does not erase newer edits when an older send clears', async () => {
|
|
await mount('a')
|
|
act(() => state?.setComposerText('submitted'))
|
|
const origin = state?.captureSendOrigin('submitted')
|
|
act(() => state?.setComposerText('new edit'))
|
|
act(() => {
|
|
if (origin) {
|
|
state?.clearDraftForSend(origin, 'submitted')
|
|
}
|
|
})
|
|
|
|
expect(state?.composerText).toBe('new edit')
|
|
})
|
|
|
|
it('stays quiet when an unconfirmed send lands in the transcript', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
await mount('a')
|
|
const origin = state?.captureSendOrigin('ping')
|
|
const onUnconfirmed = vi.fn()
|
|
act(() => {
|
|
if (origin) {
|
|
state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed)
|
|
}
|
|
})
|
|
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, { tabId: 'a', messages: [userTextMessage('m1', 'ping')] })
|
|
)
|
|
)
|
|
|
|
act(() => vi.advanceTimersByTime(30_000))
|
|
expect(onUnconfirmed).not.toHaveBeenCalled()
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('reconciles an image-only unconfirmed send against the next user turn (no false warning)', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
await mount('a')
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] })
|
|
)
|
|
)
|
|
// Image-only send: empty text, so it can only reconcile against a new user turn.
|
|
const origin = state?.captureSendOrigin('')
|
|
const onUnconfirmed = vi.fn()
|
|
act(() => {
|
|
if (origin) {
|
|
state?.holdUnconfirmedSend(origin, '', onUnconfirmed)
|
|
}
|
|
})
|
|
|
|
// An agent reply must not confirm it...
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, {
|
|
tabId: 'a',
|
|
messages: [assistantTextMessage('a1', 'hi'), assistantTextMessage('a2', 'ok')]
|
|
})
|
|
)
|
|
)
|
|
// ...but the user's own turn landing does, so the deadline never warns.
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, {
|
|
tabId: 'a',
|
|
messages: [
|
|
assistantTextMessage('a1', 'hi'),
|
|
assistantTextMessage('a2', 'ok'),
|
|
userTextMessage('u1', '')
|
|
]
|
|
})
|
|
)
|
|
)
|
|
act(() => vi.advanceTimersByTime(30_000))
|
|
expect(onUnconfirmed).not.toHaveBeenCalled()
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('clears image-only echoes one per landed user turn, not all at once', async () => {
|
|
await mount('a')
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] })
|
|
)
|
|
)
|
|
const origin = state?.captureSendOrigin('')
|
|
act(() => {
|
|
if (origin) {
|
|
state?.acceptSend(origin, '', ['file:///a.jpg'])
|
|
state?.acceptSend(origin, '', ['file:///b.jpg'])
|
|
}
|
|
})
|
|
expect(state?.pending).toHaveLength(2)
|
|
|
|
// Only one image echo has landed — exactly one photo reconciles.
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, {
|
|
tabId: 'a',
|
|
messages: [
|
|
assistantTextMessage('a1', 'hi'),
|
|
userTextMessage('u1', '[Image: source: /tmp/a.png]')
|
|
]
|
|
})
|
|
)
|
|
)
|
|
expect(state?.pending.map((pending) => pending.images)).toEqual([['file:///b.jpg']])
|
|
})
|
|
|
|
it('registers no deadline when the transcript echo beat the ambiguous RPC rejection', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
await mount('a')
|
|
const origin = state?.captureSendOrigin('ping')
|
|
const onUnconfirmed = vi.fn()
|
|
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, { tabId: 'a', messages: [userTextMessage('m1', 'ping')] })
|
|
)
|
|
)
|
|
act(() => {
|
|
if (origin) {
|
|
state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed)
|
|
}
|
|
})
|
|
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
act(() => vi.advanceTimersByTime(30_000))
|
|
expect(onUnconfirmed).not.toHaveBeenCalled()
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('surfaces uncertainty when no echo lands before the deadline', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
await mount('a')
|
|
const origin = state?.captureSendOrigin('ping')
|
|
const onUnconfirmed = vi.fn()
|
|
act(() => {
|
|
if (origin) {
|
|
state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed)
|
|
}
|
|
})
|
|
|
|
act(() => vi.advanceTimersByTime(19_999))
|
|
expect(onUnconfirmed).not.toHaveBeenCalled()
|
|
act(() => vi.advanceTimersByTime(1))
|
|
expect(onUnconfirmed).toHaveBeenCalledTimes(1)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('does not confirm an unconfirmed send against an older identical turn', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
await mount('a')
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, { tabId: 'a', messages: [userTextMessage('old', 'ping')] })
|
|
)
|
|
)
|
|
act(() => state?.setComposerText('ping'))
|
|
const origin = state?.captureSendOrigin('ping')
|
|
const onUnconfirmed = vi.fn()
|
|
act(() => {
|
|
if (origin) {
|
|
state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed)
|
|
}
|
|
})
|
|
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, {
|
|
tabId: 'a',
|
|
messages: [userTextMessage('old', 'ping'), assistantTextMessage('other', 'working')]
|
|
})
|
|
)
|
|
)
|
|
expect(state?.composerText).toBe('ping')
|
|
|
|
act(() => vi.advanceTimersByTime(30_000))
|
|
expect(onUnconfirmed).toHaveBeenCalledTimes(1)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('does not confirm an unconfirmed send when pagination prepends an older identical turn', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
await mount('a')
|
|
const anchor = assistantTextMessage('anchor', 'working')
|
|
await act(async () =>
|
|
renderer?.update(createElement(Harness, { tabId: 'a', messages: [anchor] }))
|
|
)
|
|
act(() => state?.setComposerText('ping'))
|
|
const origin = state?.captureSendOrigin('ping')
|
|
const onUnconfirmed = vi.fn()
|
|
act(() => {
|
|
if (origin) {
|
|
state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed)
|
|
}
|
|
})
|
|
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, {
|
|
tabId: 'a',
|
|
messages: [userTextMessage('older', 'ping'), anchor]
|
|
})
|
|
)
|
|
)
|
|
expect(state?.composerText).toBe('ping')
|
|
|
|
act(() => vi.advanceTimersByTime(30_000))
|
|
expect(onUnconfirmed).toHaveBeenCalledTimes(1)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('requires one new transcript echo per repeated unconfirmed send', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
await mount('a')
|
|
act(() => state?.setComposerText('ping'))
|
|
const origin = state?.captureSendOrigin('ping')
|
|
const firstUnconfirmed = vi.fn()
|
|
const secondUnconfirmed = vi.fn()
|
|
act(() => {
|
|
if (origin) {
|
|
state?.holdUnconfirmedSend(origin, 'ping', firstUnconfirmed)
|
|
state?.holdUnconfirmedSend(origin, 'ping', secondUnconfirmed)
|
|
}
|
|
})
|
|
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, { tabId: 'a', messages: [userTextMessage('echo-1', 'ping')] })
|
|
)
|
|
)
|
|
act(() => vi.advanceTimersByTime(30_000))
|
|
|
|
expect(firstUnconfirmed).not.toHaveBeenCalled()
|
|
expect(secondUnconfirmed).toHaveBeenCalledTimes(1)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('does not retain a deadline when an ambiguous send settles after unmount', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
await mount('a')
|
|
const origin = state?.captureSendOrigin('ping')
|
|
const holdUnconfirmedSend = state?.holdUnconfirmedSend
|
|
const onUnconfirmed = vi.fn()
|
|
act(() => renderer?.unmount())
|
|
renderer = null
|
|
|
|
act(() => {
|
|
if (origin) {
|
|
holdUnconfirmedSend?.(origin, 'ping', onUnconfirmed)
|
|
}
|
|
})
|
|
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
act(() => vi.advanceTimersByTime(30_000))
|
|
expect(onUnconfirmed).not.toHaveBeenCalled()
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('does not erase newer edits when an unconfirmed send lands', async () => {
|
|
await mount('a')
|
|
act(() => state?.setComposerText('submitted'))
|
|
const origin = state?.captureSendOrigin('submitted')
|
|
act(() => {
|
|
if (origin) {
|
|
state?.holdUnconfirmedSend(origin, 'submitted', vi.fn())
|
|
}
|
|
})
|
|
act(() => state?.setComposerText('new edit'))
|
|
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, { tabId: 'a', messages: [userTextMessage('m1', 'submitted')] })
|
|
)
|
|
)
|
|
expect(state?.composerText).toBe('new edit')
|
|
})
|
|
|
|
it('does not confirm an old session send from an identical turn in its replacement', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
await mount('a')
|
|
act(() => state?.setComposerText('ping'))
|
|
const origin = state?.captureSendOrigin('ping')
|
|
const onUnconfirmed = vi.fn()
|
|
act(() => {
|
|
if (origin) {
|
|
state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed)
|
|
}
|
|
})
|
|
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, {
|
|
tabId: 'a',
|
|
sessionId: 'replacement',
|
|
messages: [userTextMessage('replacement-message', 'ping')]
|
|
})
|
|
)
|
|
)
|
|
|
|
expect(state?.composerText).toBe('ping')
|
|
act(() => vi.advanceTimersByTime(30_000))
|
|
expect(onUnconfirmed).toHaveBeenCalledTimes(1)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('preserves first-send images through session assignment and transcript replacement', async () => {
|
|
await mount('a')
|
|
await act(async () => renderer?.update(createElement(Harness, { tabId: 'a', sessionId: null })))
|
|
const images = ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg']
|
|
act(() => state?.setComposerText('look'))
|
|
|
|
const origin = state?.captureSendOrigin('look')
|
|
expect(origin).toMatchObject({ pendingKey: null })
|
|
act(() => {
|
|
if (origin) {
|
|
state?.clearDraftForSend(origin, 'look')
|
|
state?.acceptSend(origin, 'look', images)
|
|
}
|
|
})
|
|
|
|
expect(state?.composerText).toBe('')
|
|
expect(state?.pending.map((pending) => pending.images)).toEqual([images])
|
|
|
|
await act(async () =>
|
|
renderer?.update(createElement(Harness, { tabId: 'a', sessionId: 'assigned' }))
|
|
)
|
|
expect(state?.pending.map((pending) => pending.images)).toEqual([images])
|
|
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, {
|
|
tabId: 'a',
|
|
sessionId: 'assigned',
|
|
messages: [
|
|
userTextMessage('source-1', '[Image: source: /tmp/a.png]'),
|
|
userTextMessage('source-2', '[Image: source: /tmp/b.png]'),
|
|
userTextMessage('source-3', '[Image: source: /tmp/c.png]')
|
|
]
|
|
})
|
|
)
|
|
)
|
|
expect(state?.pending.map((pending) => pending.images)).toEqual([images])
|
|
|
|
await act(async () =>
|
|
renderer?.update(
|
|
createElement(Harness, {
|
|
tabId: 'a',
|
|
sessionId: 'assigned',
|
|
messages: [
|
|
userTextMessage('source-1', '[Image: source: /tmp/a.png]'),
|
|
userTextMessage('source-2', '[Image: source: /tmp/b.png]'),
|
|
userTextMessage('source-3', '[Image: source: /tmp/c.png]'),
|
|
userTextMessage('prompt', '[Image #1] [Image #2] [Image #3] look')
|
|
]
|
|
})
|
|
)
|
|
)
|
|
expect(state?.pending).toEqual([])
|
|
expect(state?.imagePreviewsByMessageId).toEqual({ prompt: images })
|
|
})
|
|
})
|