Skip to content
Closed
Original file line number Diff line number Diff line change
Expand Up @@ -440,9 +440,91 @@
'user-optimistic'
])
})

// #70720: the gateway persists an attached image as a leading `@image:<path>`
// directive line, while the local optimistic composer keeps it as separate
// `attachmentRefs`. A naive text compare (chatMessageText a === b) therefore
// always mismatched whenever an image was attached and re-appended the
// optimistic row as a distinct, duplicate user bubble. Both sides must now
// reduce to the same visible text via textWithoutImageRefs.
it('does not duplicate the optimistic image turn when the persisted turn carries @image refs', () => {
const previous = [
msg('1-user', 'user', 'first'),
msg('2-assistant', 'assistant', 'first answer'),
msg('user-optimistic', 'user', 'what is in this photo?', {
attachmentRefs: ['@image:/tmp/cat.png']
})
]

const next = [
msg('1-user-stored', 'user', 'first'),
msg('2-assistant-stored', 'assistant', 'first answer'),
msg('3-user-stored', 'user', '@image:/tmp/cat.png\nwhat is in this photo?')
]

expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next)
})

it('still keeps a genuinely uncommitted optimistic image turn when the persisted text differs', () => {
const previous = [
msg('1-user', 'user', 'first'),
msg('2-assistant', 'assistant', 'first answer'),
msg('user-optimistic', 'user', 'a different caption', {
attachmentRefs: ['@image:/tmp/cat.png']
})
]

// Persisted turn has a different caption — the optimistic row is still
// uncommitted and must survive (image-aware compare must not over-correct).
const next = [
msg('1-user-stored', 'user', 'first'),
msg('2-assistant-stored', 'assistant', 'first answer'),
msg('3-user-stored', 'user', '@image:/tmp/cat.png\nwhat is in this photo?')
]

expect(preserveLocalPendingTurnMessages(next, previous).map(message => message.id)).toEqual([
'1-user-stored',
'2-assistant-stored',
'3-user-stored',
'user-optimistic'
])
})
})

describe('appendLiveSessionProjection', () => {
it('does not duplicate the inflight user when the persisted turn carries @image refs', () => {
// By the time a stored transcript reaches appendLiveSessionProjection it
// has already been run through toChatMessages, so the @image directive has
// been lifted into attachmentRefs and the visible text is the bare caption.
const stored = [
msg('stored-user', 'user', 'current running prompt', {
attachmentRefs: ['@image:/tmp/cat.png']
}),
msg('stored-assistant', 'assistant', 'earlier answer')
]

const restored = appendLiveSessionProjection(stored, {
session_id: 'runtime-1',
inflight: {
user: 'current running prompt',
assistant: 'partial answer',
streaming: true
}
})

// The persisted user already carries the same visible text (the attachment
// lives in attachmentRefs on both sides), so the inflight *user* projection
// must be suppressed — exactly one user row, no duplicated bubble stacked
// on top of the persisted one. The live assistant tail is still projected.
const userRows = restored.filter(message => message.role === 'user')
expect(userRows).toHaveLength(1)
expect(userRows[0].id).toBe('stored-user')
const userText = userRows[0].parts

Check warning on line 522 in apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
.map(part => ('text' in part ? part.text : ''))
.join('')
expect(userText).toBe('current running prompt')

Check warning on line 525 in apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
})

it('restores the running turn and accepted queued prompt after a renderer restart', () => {
const stored = [msg('stored-user', 'user', 'earlier'), msg('stored-assistant', 'assistant', 'earlier answer')]

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { getSession } from '@/hermes'
import { assistantTextPart, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
import { embeddedImageUrls, textWithoutEmbeddedImages, textWithoutImageRefs } from '@/lib/embedded-images'
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
import { requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
Expand Down Expand Up @@ -306,7 +306,7 @@
if (
isOptimisticUser &&
latestAuthoritativeUser &&
chatMessageText(latestAuthoritativeUser).trim() === chatMessageText(message).trim()
textWithoutImageRefs(chatMessageText(latestAuthoritativeUser)) === textWithoutImageRefs(chatMessageText(message))
) {
continue
}
Expand All @@ -318,7 +318,7 @@
continue
}

if (chatMessageText(authoritative).trim() === chatMessageText(message).trim()) {
if (textWithoutImageRefs(chatMessageText(authoritative)) === textWithoutImageRefs(chatMessageText(message))) {
continue
}
}
Expand Down Expand Up @@ -359,7 +359,8 @@
// Only suppress the projection when the latest authoritative user row is the
// same turn — older identical prompts must not hide a newly accepted repeat.
const latestUser = [...messages].reverse().find(message => message.role === 'user')
const inflightUserAlreadyPersisted = latestUser && chatMessageText(latestUser).trim() === inflightUser
const inflightUserAlreadyPersisted =

Check warning on line 362 in apps/desktop/src/app/session/hooks/use-session-actions/utils.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
latestUser && textWithoutImageRefs(chatMessageText(latestUser)) === textWithoutImageRefs(inflightUser)

if (inflightUser && !inflightUserAlreadyPersisted) {
projected.push({
Expand Down
19 changes: 14 additions & 5 deletions apps/desktop/src/components/assistant-ui/directive-text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -351,19 +351,28 @@ export function DirectiveContent({ text }: { text: string }) {
const { cleanedText, images } = useMemo(() => safeEmbeddedImages(text ?? ''), [text])
const segments = useMemo(() => safeDirectiveSegments(cleanedText), [cleanedText])

// `@image:<path>` directives render as a block-level thumbnail row (like
// embedded base64 images below), not inline mid-text — otherwise a large
// thumbnail gets wedged between words and breaks the text's line flow.
const imageSegments = segments.filter(
(segment): segment is Extract<Unstable_DirectiveSegment, { kind: 'mention' }> =>
segment.kind === 'mention' && segment.type === 'image'
)

return (
<span className="whitespace-pre-line" data-slot="aui_directive-text">
{segments.map((segment, index) =>
segment.kind === 'text' ? (
<Fragment key={`t-${index}`}>{segment.text}</Fragment>
) : segment.type === 'image' ? (
<DirectiveImage id={segment.id} key={`img-${index}-${segment.id}`} label={segment.label} />
) : (
) : segment.type === 'image' ? null : (
<DirectiveChip id={segment.id} key={`m-${index}-${segment.id}`} label={segment.label} type={segment.type} />
)
)}
{images.length > 0 && (
{(imageSegments.length > 0 || images.length > 0) && (
<span className="mt-2 flex flex-wrap gap-2" data-slot="aui_embedded-images">
{imageSegments.map((segment, index) => (
<DirectiveImage id={segment.id} key={`img-ref-${index}-${segment.id}`} label={segment.label} />
))}
{images.map((src, index) => (
<ZoomableImage
alt=""
Expand Down Expand Up @@ -430,7 +439,7 @@ const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => {
return (
<ZoomableImage
alt={label}
className="max-h-32 max-w-48 rounded-md border border-border/40 object-contain"
className="max-h-48 max-w-full rounded-lg border border-border/60 object-contain"
draggable={false}
slot="aui_directive-image"
src={src}
Expand Down
41 changes: 41 additions & 0 deletions apps/desktop/src/lib/chat-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,47 @@ describe('toChatMessages', () => {
expect(chatMessageText(message)).toBe('Here you go.')
})

it('lifts @image directive lines into attachmentRefs instead of inline text', () => {
const [message] = toChatMessages([
{
role: 'user',
content: '@image:/tmp/cat.png\nwhat is in this photo?',
timestamp: 1
}
])

expect(chatMessageText(message)).toBe('what is in this photo?')
expect((message as { attachmentRefs?: string[] }).attachmentRefs).toEqual(['@image:/tmp/cat.png'])
})

it('keeps a user turn that carried only an attached image (no caption)', () => {
const [message] = toChatMessages([
{
role: 'user',
content: '@image:/tmp/cat.png',
timestamp: 1
}
])

// The bubble has no visible text, but must survive the empty-turn filter
// because it carries attachment refs — otherwise a stand-alone attachment
// vanishes from the transcript after a session switch / restart.
expect(chatMessageText(message)).toBe('')
expect((message as { attachmentRefs?: string[] }).attachmentRefs).toEqual(['@image:/tmp/cat.png'])
})

it('leaves a plain user prompt without attachment refs untouched', () => {
const [message] = toChatMessages([
{
role: 'user',
content: 'just a question',
timestamp: 1
}
])

expect((message as { attachmentRefs?: string[] }).attachmentRefs).toBeUndefined()
})

it('coerces non-string message content without throwing', () => {
const [message] = toChatMessages([
{
Expand Down
23 changes: 19 additions & 4 deletions apps/desktop/src/lib/chat-messages.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ThreadMessageLike } from '@assistant-ui/react'
import type { BillingBlock } from '@hermes/shared'

import { extractImageRefs } from '@/lib/embedded-images'
import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images'
import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media'
import { normalize } from '@/lib/text'
Expand Down Expand Up @@ -912,7 +913,7 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {

const content = message.content || message.text || message.context || message.name

const displayContent = transcriptContent(
const rawDisplayContent = transcriptContent(
message.display_kind,
timelineDisplayContent(message, displayContentForMessage(message.role, content))
)
Expand All @@ -922,6 +923,17 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
? 'system'
: message.role

// Persisted user turns carry `@image:<path>` directive lines inline in
// the text (see tui_gateway/server.py's persist-time rewrite). The
// read-only bubble clamps its body to ~2 lines, and a large inline image
// thumbnail pushes any caption text below the clamp's visible area — so
// pull image refs out into `attachmentRefs` (same shape the local
// optimistic composer already uses) and render them via the dedicated
// attachments row below the bubble instead.
const imageRefExtraction = displayRole === 'user' && rawDisplayContent ? extractImageRefs(rawDisplayContent) : null
const displayContent = imageRefExtraction ? imageRefExtraction.cleanedText : rawDisplayContent
const extractedAttachmentRefs = imageRefExtraction?.refs.length ? imageRefExtraction.refs : undefined

const parts: ChatMessagePart[] = []

const reasoning =
Expand All @@ -941,7 +953,7 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
parts.push(...message.tool_calls.map((call, callIndex) => toolPartFromStoredCall(call, callIndex)))
}

if (!parts.length) {
if (!parts.length && !extractedAttachmentRefs?.length) {
if (message.role !== 'assistant') {
flushPendingTools(index)
activeAssistantIndex = null
Expand Down Expand Up @@ -991,7 +1003,8 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
id: `${message.timestamp || Date.now()}-${index}-${displayRole}`,
role: displayRole,
parts,
timestamp: message.timestamp
timestamp: message.timestamp,
...(extractedAttachmentRefs ? { attachmentRefs: extractedAttachmentRefs } : {})
})

activeAssistantIndex = message.role === 'assistant' ? result.length - 1 : null
Expand All @@ -1003,7 +1016,9 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
)

return withUniqueToolCallIds(
withoutGeneratedImageEchoes.filter(m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text'))
withoutGeneratedImageEchoes.filter(
m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text') || m.attachmentRefs?.length
)
)
}

Expand Down
49 changes: 48 additions & 1 deletion apps/desktop/src/lib/embedded-images.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'

import { extractEmbeddedImages } from './embedded-images'
import { extractEmbeddedImages, extractImageRefs, textWithoutImageRefs } from './embedded-images'

const SAMPLE_PNG_DATA_URL = 'data:image/png;base64,' + 'A'.repeat(120)

Expand Down Expand Up @@ -42,3 +42,50 @@ describe('extractEmbeddedImages', () => {
expect(result.images[0]).toHaveLength(hugeDataUrl.length)
})
})

describe('textWithoutImageRefs', () => {
it('leaves plain text untouched', () => {
expect(textWithoutImageRefs('just a question')).toBe('just a question')
})

it('strips a single leading @image directive line', () => {
expect(textWithoutImageRefs('@image:/tmp/cat.png\nwhat is this?')).toBe('what is this?')
})

it('strips multiple @image directive lines and trims', () => {
const input = '@image:/tmp/a.png\n@image:/tmp/b.png\n describe both '

expect(textWithoutImageRefs(input)).toBe('describe both')
})

it('does not treat an inline @image mention as a directive line', () => {
// Only full-line leading directives are stripped, matching the gateway's
// persist-time rewrite. A bare mention mid-prose is preserved.
expect(textWithoutImageRefs('see @image:/tmp/cat.png here')).toBe('see @image:/tmp/cat.png here')
})
})

describe('extractImageRefs', () => {
it('returns the text untouched and no refs when there are no directives', () => {
expect(extractImageRefs('a normal prompt')).toEqual({ cleanedText: 'a normal prompt', refs: [] })
})

it('lifts leading @image directive lines into refs and clears the text', () => {
const result = extractImageRefs('@image:/tmp/cat.png\nwhat do you see?')

expect(result).toEqual({ cleanedText: 'what do you see?', refs: ['@image:/tmp/cat.png'] })
})

it('collects multiple refs in order', () => {
const result = extractImageRefs('@image:/tmp/a.png\n@image:/tmp/b.png\ncompare them')

expect(result.cleanedText).toBe('compare them')
expect(result.refs).toEqual(['@image:/tmp/a.png', '@image:/tmp/b.png'])
})

it('keeps only the directive lines when there is no trailing text', () => {
const result = extractImageRefs('@image:/tmp/only.png')

expect(result).toEqual({ cleanedText: '', refs: ['@image:/tmp/only.png'] })
})
})
37 changes: 37 additions & 0 deletions apps/desktop/src/lib/embedded-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,40 @@ export function embeddedImageUrls(text: string): string[] {
export function textWithoutEmbeddedImages(text: string): string {
return extractEmbeddedImages(text).cleanedText
}

// The gateway persists attached images as `@image:<path>` directive lines
// (see tui_gateway/server.py's persist-time rewrite), prepended before the
// user's own text. The composer's own optimistic/local turn never carries
// this prefix — it keeps the attachment as separate `attachmentRefs`
// metadata, not inline text. Comparing raw chatMessageText between the
// optimistic turn and the authoritative (persisted) turn therefore always
// mismatches whenever an image was attached, which defeats the "is this the
// same turn" checks in preserveLocalPendingTurnMessages / appendLiveSessionProjection
// and re-appends the optimistic row as if it were a distinct, unconfirmed
// turn — a duplicated user bubble. Strip the directive line(s) before any
// such equality comparison so both sides reduce to the same visible text.
const IMAGE_REF_LINE_RE = /^@image:[^\n]*\n?/gm

export function textWithoutImageRefs(text: string): string {
return text.replace(IMAGE_REF_LINE_RE, '').trim()
}

// Same directive lines as textWithoutImageRefs, but keeps them instead of
// discarding — used when converting persisted server messages into
// ChatMessage/ThreadMessageLike shape, where `@image:<path>` refs need to
// move from inline text into the `attachmentRefs` metadata field (mirroring
// how the local optimistic composer represents attachments) rather than stay
// embedded in the bubble's clamped text body, where a large inline thumbnail
// pushes the caption text out of the clamp's visible area.
export function extractImageRefs(text: string): { cleanedText: string; refs: string[] } {
const refs: string[] = []
const cleanedText = text
.replace(IMAGE_REF_LINE_RE, match => {
refs.push(match.trim())

return ''
})
.trim()

return { cleanedText, refs }
}
2 changes: 1 addition & 1 deletion tests/test_lazy_session_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ def __init__(self):
self.session_id = "pre-compress-key"
self._cached_system_prompt = ""

def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
def run_conversation(self, prompt, conversation_history=None, stream_callback=None, **_kwargs):
# Simulate what _compress_context does: rotate session_id
self.session_id = "post-compress-key"
return {
Expand Down
Loading
Loading