Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions apps/desktop/src/app/chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import {
sessionPinId,
shouldMigrateComposerScope
} from '@/store/session'
import { sessionTileDelegate } from '@/store/session-states'
import { $transcriptTailBySessionId } from '@/store/transcript-tail'
import { isAuxiliaryWindow, isWatchWindow } from '@/store/windows'
import type { ModelOptionsResponse } from '@/types/hermes'

Expand All @@ -65,6 +67,7 @@ import { ScrollToBottomButton } from './scroll-to-bottom-button'
import { useSessionView } from './session-view'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
import { threadLoadingState } from './thread-loading'
import { backfillOlderTranscriptPage, mergeOlderTranscriptPage, transcriptBackfillAvailable } from './transcript-backfill'
import { advanceTranscriptWindow, type TranscriptWindowState } from './transcript-window'

interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
Expand Down Expand Up @@ -253,9 +256,42 @@ function ChatRuntimeBoundary({

const runtimeMessageRepository = useRuntimeMessageRepository(windowedMessages)

const expandWindow = useCallback(() => setWindowPages(pages => pages + 1), [])
const storedId = useStore(view.$storedId)
// Subscribed (not read imperatively) so the "Show earlier" affordance
// appears/retires as tail hydrations and backfill pages record their state.
const transcriptTailStates = useStore($transcriptTailBySessionId)
const restBackfillAvailable = Boolean(storedId && transcriptTailStates[storedId]?.possiblyTruncated)

const expandWindow = useCallback(() => {
// The store window still holds older messages: growing pages is enough.
// Otherwise the whole in-memory transcript is already materialized — if
// the REST tail hydration was truncated, fetch the next older page and
// PREPEND it to the session store before growing, so the grown window has
// something older to show. Fire-and-forget: the prepend lands through the
// session-state write path and re-renders this boundary.
if (!windowStateRef.current?.window.windowed && runtimeId && storedId && transcriptBackfillAvailable(storedId)) {
void backfillOlderTranscriptPage({
storedSessionId: storedId,
// Stale-response guard: a session switch remounts/re-keys this view;
// checking the live atoms (not captured props) discards a page that
// resolves after the user moved on — same pattern as isCurrentResume.
isCurrent: () => view.$storedId.get() === storedId && view.$runtimeId.get() === runtimeId,
applyOlderPage: olderPage => {
sessionTileDelegate()?.updateSession(runtimeId, state => {
const merged = mergeOlderTranscriptPage(state.messages, olderPage)

return merged === state.messages ? state : { ...state, messages: merged }
})
}
})
}

setWindowPages(pages => pages + 1)
}, [runtimeId, storedId, view])

const olderAvailable = windowed || restBackfillAvailable

const transcriptWindow = useMemo(() => ({ olderAvailable: windowed, expandWindow }), [expandWindow, windowed])
const transcriptWindow = useMemo(() => ({ olderAvailable, expandWindow }), [expandWindow, olderAvailable])

const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
messageRepository: runtimeMessageRepository,
Expand Down
285 changes: 285 additions & 0 deletions apps/desktop/src/app/chat/transcript-backfill.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import type { ChatMessage } from '@/lib/chat-messages'
import {
$transcriptTailBySessionId,
recordTranscriptTail,
transcriptTailState
} from '@/store/transcript-tail'

import {
_resetTranscriptBackfillForTests,
backfillOlderTranscriptPage,
graftRefreshedTailOntoBackfill,
mergeOlderTranscriptPage,
transcriptBackfillAvailable
} from './transcript-backfill'

vi.mock('@/hermes', () => ({
getOlderSessionMessages: vi.fn()
}))

const { getOlderSessionMessages } = await import('@/hermes')

const chat = (id: string, rowId?: number): ChatMessage => ({
id,
role: 'user',
parts: [{ type: 'text', text: id }],
...(rowId !== undefined ? { rowId } : {})
})

// A stored SessionMessage row: distinct timestamps keep toChatMessages ids
// unique and the row id survives as ChatMessage.rowId.
const row = (rowId: number, text: string) => ({
id: rowId,
role: 'user' as const,
content: text,
timestamp: 1_000 + rowId
})

describe('transcript tail bookkeeping', () => {
beforeEach(() => {
$transcriptTailBySessionId.set({})
})

it('marks a full page as possibly truncated with the next offset', () => {
recordTranscriptTail(
'stored-1',
{
messages: Array.from({ length: 120 }, (_, index) => row(index + 500, `m${index}`)),
pagination: { limit: 120, offset: 0, order: 'latest', returned: 120 }
},
'work'
)

expect(transcriptTailState('stored-1')).toEqual({ nextOffset: 120, possiblyTruncated: true, profile: 'work' })
expect(transcriptBackfillAvailable('stored-1')).toBe(true)
})

it('marks a short page as complete', () => {
recordTranscriptTail('stored-1', {
messages: [row(1, 'only')],
pagination: { limit: 120, offset: 0, order: 'latest', returned: 1 }
})

expect(transcriptBackfillAvailable('stored-1')).toBe(false)
})

it('treats a legacy response without pagination metadata as complete', () => {
recordTranscriptTail('stored-1', {
messages: Array.from({ length: 700 }, (_, index) => row(index, `m${index}`))
})

expect(transcriptBackfillAvailable('stored-1')).toBe(false)
})
})

describe('mergeOlderTranscriptPage', () => {
it('prepends the older page and preserves chronological order', () => {
const existing = [chat('c', 3), chat('d', 4)]
const older = [chat('a', 1), chat('b', 2)]

expect(mergeOlderTranscriptPage(existing, older).map(m => m.id)).toEqual(['a', 'b', 'c', 'd'])
})

it('dedupes rows the store already holds by durable row id', () => {
const existing = [chat('b', 2), chat('c', 3)]
// Offset drift: the fetched page overlaps one row we already have.
const older = [chat('a', 1), chat('b-refetched', 2)]

expect(mergeOlderTranscriptPage(existing, older).map(m => m.rowId)).toEqual([1, 2, 3])
})

it('keeps reference identity when every older row is already present', () => {
const existing = [chat('a', 1), chat('b', 2)]
const older = [chat('a', 1)]

expect(mergeOlderTranscriptPage(existing, older)).toBe(existing)
})

it('refuses to paint an older page as the whole transcript', () => {
const existing: ChatMessage[] = []

expect(mergeOlderTranscriptPage(existing, [chat('a', 1)])).toBe(existing)
})
})

describe('graftRefreshedTailOntoBackfill', () => {
it('keeps the backfilled prefix when the refreshed tail anchors inside it', () => {
const previous = [chat('a', 1), chat('b', 2), chat('c', 3)]
const refreshed = [chat('b', 2), chat('c', 3), chat('d', 4)]

expect(graftRefreshedTailOntoBackfill(refreshed, previous).map(m => m.rowId)).toEqual([1, 2, 3, 4])
})

it('returns the refreshed tail unchanged when no anchor is found', () => {
const previous = [chat('x', 90), chat('y', 91), chat('z', 92)]
const refreshed = [chat('p', 200), chat('q', 201)]

expect(graftRefreshedTailOntoBackfill(refreshed, previous)).toBe(refreshed)
})

it('returns the refreshed tail when it is not shorter than the previous transcript', () => {
const previous = [chat('a', 1)]
const refreshed = [chat('a', 1), chat('b', 2)]

expect(graftRefreshedTailOntoBackfill(refreshed, previous)).toBe(refreshed)
})
})

describe('backfillOlderTranscriptPage', () => {
beforeEach(() => {
$transcriptTailBySessionId.set({})
_resetTranscriptBackfillForTests()
vi.mocked(getOlderSessionMessages).mockReset()
})

afterEach(() => {
vi.restoreAllMocks()
})

const truncatedTail = (nextOffset = 120) => {
recordTranscriptTail('stored-1', {
messages: Array.from({ length: 120 }, (_, index) => row(index + nextOffset, `tail${index}`)),
pagination: { limit: 120, offset: 0, order: 'latest', returned: 120 }
})
}

it('fetches the recorded next offset and applies the converted page', async () => {
truncatedTail()
vi.mocked(getOlderSessionMessages).mockResolvedValue({
messages: [row(1, 'older-1'), row(2, 'older-2')],
pagination: { limit: 120, offset: 120, order: 'latest', returned: 2 },
session_id: 'stored-1'
} as never)

const applyOlderPage = vi.fn()

const applied = await backfillOlderTranscriptPage({
storedSessionId: 'stored-1',
isCurrent: () => true,
applyOlderPage
})

expect(applied).toBe(true)
expect(getOlderSessionMessages).toHaveBeenCalledWith('stored-1', undefined, 120)
expect(applyOlderPage).toHaveBeenCalledTimes(1)
expect(applyOlderPage.mock.calls[0][0].map((m: ChatMessage) => m.rowId)).toEqual([1, 2])
// A short older page means the transcript is now fully loaded.
expect(transcriptBackfillAvailable('stored-1')).toBe(false)
})

it('keeps backfill available while pages keep coming back full', async () => {
truncatedTail()
vi.mocked(getOlderSessionMessages).mockResolvedValue({
messages: Array.from({ length: 120 }, (_, index) => row(index, `older${index}`)),
pagination: { limit: 120, offset: 120, order: 'latest', returned: 120 },
session_id: 'stored-1'
} as never)

await backfillOlderTranscriptPage({ storedSessionId: 'stored-1', isCurrent: () => true, applyOlderPage: vi.fn() })

expect(transcriptTailState('stored-1')).toMatchObject({ nextOffset: 240, possiblyTruncated: true })
})

it('falls back to the full transcript when a legacy backend returns no pagination metadata', async () => {
truncatedTail()
// Legacy backend: ignores limit/offset/order and one-shots everything.
vi.mocked(getOlderSessionMessages).mockResolvedValue({
messages: Array.from({ length: 700 }, (_, index) => row(index, `full${index}`)),
session_id: 'stored-1'
} as never)

const applyOlderPage = vi.fn()

const applied = await backfillOlderTranscriptPage({
storedSessionId: 'stored-1',
isCurrent: () => true,
applyOlderPage
})

expect(applied).toBe(true)
expect(applyOlderPage.mock.calls[0][0]).toHaveLength(700)
// One-shot full transcript: the REST action retires.
expect(transcriptBackfillAvailable('stored-1')).toBe(false)
})

it('discards a stale response after a session switch', async () => {
truncatedTail()
vi.mocked(getOlderSessionMessages).mockResolvedValue({
messages: [row(1, 'older-1')],
pagination: { limit: 120, offset: 120, order: 'latest', returned: 1 },
session_id: 'stored-1'
} as never)

const applyOlderPage = vi.fn()

const applied = await backfillOlderTranscriptPage({
storedSessionId: 'stored-1',
// The user switched sessions while the page was in flight.
isCurrent: () => false,
applyOlderPage
})

expect(applied).toBe(false)
expect(applyOlderPage).not.toHaveBeenCalled()
// Bookkeeping untouched: the next visit re-records the tail anyway.
expect(transcriptTailState('stored-1')).toMatchObject({ nextOffset: 120, possiblyTruncated: true })
})

it('shares one in-flight fetch per stored session', async () => {
truncatedTail()

let resolvePage: (value: unknown) => void = () => {}

vi.mocked(getOlderSessionMessages).mockReturnValue(
new Promise(resolve => {
resolvePage = resolve
}) as never
)

const first = backfillOlderTranscriptPage({ storedSessionId: 'stored-1', isCurrent: () => true, applyOlderPage: vi.fn() })
const second = backfillOlderTranscriptPage({ storedSessionId: 'stored-1', isCurrent: () => true, applyOlderPage: vi.fn() })

expect(second).toBe(first)
expect(getOlderSessionMessages).toHaveBeenCalledTimes(1)

resolvePage({
messages: [row(1, 'older-1')],
pagination: { limit: 120, offset: 120, order: 'latest', returned: 1 },
session_id: 'stored-1'
})

await first
})

it('resolves false without fetching when the tail is not truncated', async () => {
recordTranscriptTail('stored-1', {
messages: [row(1, 'only')],
pagination: { limit: 120, offset: 0, order: 'latest', returned: 1 }
})

const applied = await backfillOlderTranscriptPage({
storedSessionId: 'stored-1',
isCurrent: () => true,
applyOlderPage: vi.fn()
})

expect(applied).toBe(false)
expect(getOlderSessionMessages).not.toHaveBeenCalled()
})

it('survives a fetch failure and leaves the action retryable', async () => {
truncatedTail()
vi.mocked(getOlderSessionMessages).mockRejectedValue(new Error('network down'))

const applied = await backfillOlderTranscriptPage({
storedSessionId: 'stored-1',
isCurrent: () => true,
applyOlderPage: vi.fn()
})

expect(applied).toBe(false)
expect(transcriptBackfillAvailable('stored-1')).toBe(true)
})
})
Loading
Loading