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
95 changes: 93 additions & 2 deletions harness-tests/core/conversation-events.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { retainArchivedMessages } from '../../src/chat/core/conversation-snapshot'
import { describe, expect, it } from 'vitest'
import { StreamProcessor } from '@tanstack/ai'
import { readMessageAttachments } from '../../src/chat/core/message-attachments'
Expand Down Expand Up @@ -105,6 +106,7 @@ describe('conversation AG-UI projection', () => {
role: 'user',
parts: [{ type: 'text', content: 'A copied message' }],
metadata: { gumInherited: true, obsolete: true },
createdAt: new Date('2026-10-01T12:00:00Z'),
},
],
}
Expand All @@ -113,8 +115,14 @@ describe('conversation AG-UI projection', () => {
const processor = new StreamProcessor()
for (const event of conversationEvents(undefined, before, 'chat'))
processor.processChunk(event)
for (const event of conversationEvents(before, after, 'chat'))
for (const event of conversationEvents(before, after, 'chat')) {
processor.processChunk(event)
expect(processor.getMessages()).toHaveLength(1)
expect(processor.getMessages()[0].parts).toEqual(before.messages[0].parts)
expect(processor.getMessages()[0].createdAt).toEqual(
before.messages[0].createdAt,
)
}
expect(processor.getMessages()).toHaveLength(1)
expect(processor.getMessages()[0]).toMatchObject({
metadata: { gumInherited: true },
Expand Down Expand Up @@ -238,6 +246,89 @@ describe('conversation AG-UI projection', () => {
})
}
})
it('keeps displayed history when completed turns leave the live server window', () => {
const before: ProjectableConversation = {
...initial(),
messages: [
{
id: 'old',
role: 'user',
parts: [{ type: 'text', content: 'Earlier' }],
},
{
id: 'current',
role: 'user',
parts: [{ type: 'text', content: 'Current' }],
},
],
}
const after: ProjectableConversation = {
...before,
archivedTurns: 1,
messages: [
before.messages[1],
{ id: 'new', role: 'user', parts: [{ type: 'text', content: 'New' }] },
],
}
const processor = new StreamProcessor()
for (const event of conversationEvents(undefined, before, 'chat'))
processor.processChunk(event)
for (const event of conversationEvents(before, after, 'chat')) {
processor.processChunk(event)
expect(processor.getMessages()[0]).toMatchObject(before.messages[0])
}
expect(processor.getMessages().map((message) => message.id)).toEqual([
'old',
'current',
'new',
])
})
it('keeps archived rows across an authoritative metadata snapshot and clears on reset', () => {
const archived: ProjectableConversation = {
...initial(),
archivedTurns: 1,
messages: [
{
id: 'current',
role: 'user',
parts: [{ type: 'text', content: 'Current' }],
metadata: { obsolete: true },
},
],
}
const displayed = [
{
id: 'earlier',
role: 'user',
parts: [{ type: 'text', content: 'Earlier' }],
},
...archived.messages,
] satisfies ProjectableConversation['messages']
const next = structuredClone(archived)
next.messages[0].metadata = undefined
const processor = new StreamProcessor()
for (const event of conversationEvents(
undefined,
{ ...archived, messages: displayed },
'chat',
))
processor.processChunk(event)
for (const event of conversationEvents(archived, next, 'chat')) {
processor.processChunk(
retainArchivedMessages(event, processor.getMessages()),
)
expect(processor.getMessages().map((message) => message.id)).toEqual([
'earlier',
'current',
])
}
expect(processor.getMessages()[1].metadata).not.toHaveProperty('obsolete')
for (const event of conversationEvents(next, initial(), 'chat'))
processor.processChunk(
retainArchivedMessages(event, processor.getMessages()),
)
expect(processor.getMessages()).toEqual([])
})
it('publishes only appended text between saves and clears transcript on reset', () => {
const old = {
...initial(),
Expand All @@ -256,7 +347,7 @@ describe('conversation AG-UI projection', () => {
messageId: 'a',
delta: ' world',
})
expect(conversationEvents(next, initial(), 'chat')[0]).toEqual({
expect(conversationEvents(next, initial(), 'chat')[0]).toMatchObject({
type: 'MESSAGES_SNAPSHOT',
messages: [],
})
Expand Down
28 changes: 13 additions & 15 deletions src/chat/components/ConversationSession.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { retainArchivedMessages } from '../core/conversation-snapshot'
import { workspacePreviewsEnabled } from '../core/workspace-panels'
import { useQueryClient } from '@tanstack/react-query'
import { projectSavedEvent } from '../core/project-events'
import { ProjectsPanel } from './ProjectsPanel'
import { isPersonalAssistant } from '../core/bot-workspace'
import './assistant-chat.css'
import { DraftSyncNotice } from './DraftSyncNotice'
import { motion } from 'motion/react'
import { useDebugDetails } from './useDebugDetails'
import { Button } from './ui/Button'
import { conversationActionRequest } from '../core/conversation-actions'
Expand Down Expand Up @@ -461,15 +461,21 @@ export function ConversationSession({
const forkMessage = onFork
? (messageId: string) => onFork(messageId, destination.conversationId)
: undefined
const displayedMessages = useRef(initialHistory.messages)
const connection = useMemo<SubscribeConnectionAdapter>(() => {
const durable = durableStreamConnection({
sendUrl: apiUrl(`${path}/send`),
readUrl: apiUrl(`${path}/stream`),
initialOffset: initialHistory.streamOffset,
emitSnapshotOnSubscribe: false,
})
const subscribe =
durable.subscribe as SubscribeConnectionAdapter['subscribe']
return {
subscribe: durable.subscribe as SubscribeConnectionAdapter['subscribe'],
subscribe: async function* (signal) {
for await (const chunk of subscribe(signal))
yield retainArchivedMessages(chunk, displayedMessages.current)
},
send: async (messages, data) => {
const message = messages.at(-1)
if (!message || !('parts' in message))
Expand Down Expand Up @@ -525,6 +531,7 @@ export function ConversationSession({
}
},
})
displayedMessages.current = chat.messages
const h = { ...metadata, messages: chat.messages }
const emptyThreadState = useRef(false)
emptyThreadState.current =
Expand Down Expand Up @@ -1121,31 +1128,23 @@ export function ConversationSession({
origin={h.copyOrigin}
/>
)}
<motion.div
layoutScroll
<div
className="messages-scroll"
ref={setScrollElement}
tabIndex={-1}
role="region"
aria-label="Conversation messages"
>
{thread && (
<motion.article
layout="position"
layoutDependency={thread.sourceMessageId}
layoutId={JSON.stringify([
'message',
thread.parentConversationId,
thread.sourceMessageId,
])}
<article
className={`thread-source message ${thread.source.role}`}
aria-label="Original message"
>
<div className="message-content">
<MessageMarkdown>{thread.source.text}</MessageMarkdown>
</div>
{thread.source.truncated && <p>Source excerpt</p>}
</motion.article>
</article>
)}
{(!!h.archivedTurns || (!!search.message && !liveTarget)) && (
<EarlierMessages
Expand Down Expand Up @@ -1190,7 +1189,6 @@ export function ConversationSession({
jumpToBottomContainer={jumpToBottomContainer}
onSelectMessage={selectMessage}
contentVersion={messageRevision}
followRequest={pendingMessages.at(-1)?.payload.messageId}
onJumpToBottom={() => navigation.onSelectMessage(undefined)}
>
{turns.map((turn, index, turns) => (
Expand Down Expand Up @@ -1441,7 +1439,7 @@ export function ConversationSession({
))}
</VirtualMessages>
)}
</motion.div>
</div>
<div className="composer-area" ref={composerArea}>
<div className="message-jump-anchor" ref={setJumpToBottomContainer} />
<RetryPreparation preparation={retryPreparation} />
Expand Down
31 changes: 4 additions & 27 deletions src/chat/components/ConversationTurn.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { motion } from 'motion/react'
import type { UIMessage, ToolResultPart, ToolCallPart } from '@tanstack/ai'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import {
Expand Down Expand Up @@ -296,18 +295,7 @@ export function ConversationTurn({
</time>
)}
{turn.prompt && (
<motion.article
layout="position"
layoutDependency={turn.prompt.id}
layoutId={
currentConversation
? JSON.stringify([
'message',
currentConversation.conversationId,
turn.prompt.id,
])
: undefined
}
<article
className="message user"
id={messageAnchorId(turn.prompt.id, messageScope)}
tabIndex={-1}
Expand Down Expand Up @@ -355,7 +343,7 @@ export function ConversationTurn({
})}
</div>
)}
</motion.article>
</article>
)}
<div className="assistant-message-block">
{interim.map((m) => (
Expand Down Expand Up @@ -497,18 +485,7 @@ export function ConversationTurn({
finalParts.some(
(part) => part.type === 'text' && part.content.trim(),
) && (
<motion.article
layout="position"
layoutDependency={final.id}
layoutId={
currentConversation
? JSON.stringify([
'message',
currentConversation.conversationId,
final.id,
])
: undefined
}
<article
className="message assistant"
id={messageAnchorId(final.id, messageScope)}
tabIndex={-1}
Expand All @@ -528,7 +505,7 @@ export function ConversationTurn({
</div>
{final.id !== responseEnd?.id &&
renderMessageThreads?.(final.id, { canCreate: false })}
</motion.article>
</article>
)}
{showResponseActions && (
<div
Expand Down
19 changes: 0 additions & 19 deletions src/chat/components/VirtualMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ export function VirtualMessages({
onJumpToBottom,
jumpToBottomContainer,
contentVersion,
followRequest,
messageScope,
}: {
children: ReactNode
Expand All @@ -52,7 +51,6 @@ export function VirtualMessages({
onSelectMessage?: (messageId: string) => void
onJumpToBottom?: () => void
jumpToBottomContainer?: HTMLDivElement | null
followRequest?: string
contentVersion?: string | number
messageScope?: string
}) {
Expand Down Expand Up @@ -114,23 +112,6 @@ export function VirtualMessages({
}
}, [rows.length, scrollElement, scrollMargin, virtualizer])

const followedRequest = useRef<string | undefined>(undefined)
useLayoutEffect(() => {
if (
!followRequest ||
followedRequest.current === followRequest ||
!scrollElement ||
scrollMargin === null ||
!rows.length
)
return
followedRequest.current = followRequest
pendingTarget.current = undefined
pendingFocus.current?.cancel()
pendingFocus.current = null
virtualizer.scrollToEnd()
}, [followRequest, scrollElement, scrollMargin, rows.length, virtualizer])

const alignTarget = () => {
if (!pendingTarget.current || !scrollElement) return
if (pendingFocus.current && !pendingFocus.current.active()) {
Expand Down
26 changes: 26 additions & 0 deletions src/chat/core/conversation-snapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { StreamChunk, UIMessage } from '@tanstack/ai'

// The server window omits archived turns, but an open transcript keeps them.
// A reset has no boundary and still replaces the entire displayed history.
export function retainArchivedMessages(
chunk: StreamChunk,
displayed: UIMessage[],
): StreamChunk {
if (chunk.type !== 'MESSAGES_SNAPSHOT') return chunk
const boundary = chunk.rawEvent?.retainedHistoryBefore
if (typeof boundary !== 'string') return chunk
const index = displayed.findIndex((message) => message.id === boundary)
if (index < 1) return chunk
return {
...chunk,
messages: [
...displayed.slice(0, index).map((message) => ({
...message,
content: message.parts
.flatMap((part) => (part.type === 'text' ? [part.content] : []))
.join(''),
})),
...chunk.messages,
],
}
}
Loading
Loading