diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index c4de8f73a7..43a43c7a48 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -7,6 +7,18 @@ import css from './MessageImage.module.css' /** Loads a session-authorized durable image URL. */ export type ImageLoader = (attachment: ImageAttachmentRef) => Promise +/** One gallery entry: a durable admitted reference, or a submission echo's local preview. */ +export type MessageImageSpec = + | { readonly attachment: ImageAttachmentRef } + | { + readonly preview: { + readonly url: string + readonly name?: string + readonly width?: number + readonly height?: number + } + } + /** Message-image strings the owner resolves from its own locale namespace. */ export interface MessageImageLabels { /** Fallback display name for an unnamed image. */ @@ -28,11 +40,13 @@ export interface MessageImageLabels { * `object-fit: cover` — and never upscaled past the image's natural size. The * crop anchor keeps the top of very tall images and the left of very wide * ones, where the informative content usually starts. */ -function singleFit(attachment: ImageAttachmentRef): { width: number; height: number; objectPosition: string } { - const natural = attachment.width / attachment.height +function singleFit( + dimensions: { readonly width: number; readonly height: number }, +): { width: number; height: number; objectPosition: string } { + const natural = dimensions.width / dimensions.height const ratio = Math.min(4, Math.max(0.25, natural)) const box = ratio >= 1 ? { width: 240, height: 240 / ratio } : { width: 240 * ratio, height: 240 } - const scale = Math.min(1, attachment.width / box.width, attachment.height / box.height) + const scale = Math.min(1, dimensions.width / box.width, dimensions.height / box.height) return { width: Math.max(1, Math.round(box.width * scale)), height: Math.max(1, Math.round(box.height * scale)), @@ -40,24 +54,35 @@ function singleFit(attachment: ImageAttachmentRef): { width: number; height: num } } +/** Intrinsic dimensions of one gallery entry; a preview's stay unknown until its intake probe resolved. */ +function dimensionsOf(image: MessageImageSpec): { readonly width: number; readonly height: number } | undefined { + if ('attachment' in image) return image.attachment + return image.preview.width !== undefined && image.preview.height !== undefined + ? { width: image.preview.width, height: image.preview.height } + : undefined +} + /** * Compact history renderer with retryable loading and click-to-open original * preview. A lone image renders at its `singleFit` size; an image among - * several renders as a fixed 64px square tile. + * several renders as a fixed 64px square tile. The preview arm displays its + * local URL directly — no loader round-trip, no failure/retry surface. * - * @param props.attachment - the durable image reference to load and bound. - * @param props.load - session-authorized URL loader. + * @param props.image - the durable reference to load, or the local preview to display. + * @param props.load - session-authorized URL loader for the durable arm. * @param props.variant - `single` for a message's lone image, `tile` otherwise. * @param props.labels - resolved strings (tooltip, loading, retry, lightbox). * @returns the bounded thumbnail button, or the retry control on failure. */ -export function MessageImage({ attachment, load, variant, labels }: { - attachment: ImageAttachmentRef +export function MessageImage({ image, load, variant, labels }: { + image: MessageImageSpec load: ImageLoader variant: 'single' | 'tile' labels: MessageImageLabels }) { - const [src, setSrc] = useState(null) + const preview = 'preview' in image ? image.preview : undefined + const attachment = 'attachment' in image ? image.attachment : undefined + const [loaded, setLoaded] = useState(null) const [error, setError] = useState(false) const [open, setOpen] = useState(false) // Retry re-arms the one load effect below, so every attempt — first load or @@ -65,20 +90,30 @@ export function MessageImage({ attachment, load, variant, labels }: { const [attempt, setAttempt] = useState(0) const request = useCallback(() => { setAttempt(a => a + 1) }, []) const close = useCallback(() => { setOpen(false) }, []) + const dimensions = useMemo(() => dimensionsOf(image), [image]) const fit = useMemo( - () => (variant === 'single' ? singleFit(attachment) : undefined), - [attachment, variant], + () => { + if (variant !== 'single') return undefined + // A preview whose intake probe has not resolved sizes as a square crop; + // the durable replacement restores the exact fit. + return dimensions === undefined + ? { width: 240, height: 240, objectPosition: 'center' } + : singleFit(dimensions) + }, + [dimensions, variant], ) useEffect(() => { + if (attachment === undefined) return let live = true setError(false) - setSrc(null) - void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) }) + setLoaded(null) + void load(attachment).then((url) => { if (live) setLoaded(url) }).catch(() => { if (live) setError(true) }) return () => { live = false } }, [attachment, load, attempt]) - const label = attachment.name ?? labels.image + const src = preview?.url ?? loaded + const label = (preview?.name ?? attachment?.name) ?? labels.image if (error) return return ( <> @@ -103,7 +138,7 @@ export function MessageImage({ attachment, load, variant, labels }: { /** Wrapping image group shared by user and assistant history: a lone image * renders large, several render as 64px square tiles (DeepSeek Chat rule). */ export function ImageGallery({ images, load, align, labels }: { - images: readonly { attachment: ImageAttachmentRef }[] + images: readonly MessageImageSpec[] load: ImageLoader align: 'start' | 'end' labels: MessageImageLabels @@ -113,7 +148,13 @@ export function ImageGallery({ images, load, align, labels }: { return (
{images.map((image, index) => ( - + ))}
) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index d7d37bfd97..5972beacdb 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -49,7 +49,7 @@ const useTrajectory: MessageImagesProps['useTrajectory'] = selector => selector( describe('MessageImage', () => { it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => { const load = vi.fn().mockResolvedValue('blob:history') - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(frame.getAttribute('style')).toContain('width: 240px') expect(frame.getAttribute('style')).toContain('height: 120px') @@ -64,7 +64,7 @@ describe('MessageImage', () => { it('ignores a click while the thumbnail is still loading', () => { const load = vi.fn(() => new Promise(() => {})) - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(view.getByText('图片加载中…')).toBeTruthy() fireEvent.click(frame) @@ -74,7 +74,7 @@ describe('MessageImage', () => { it('falls back to the image label for an unnamed attachment', async () => { const { name: _named, ...unnamed } = attachment const load = vi.fn().mockResolvedValue('blob:unnamed') - const view = render() + const view = render() await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() }) expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy() }) @@ -84,7 +84,7 @@ describe('MessageImage', () => { .mockRejectedValueOnce(new Error('offline')) .mockRejectedValueOnce(new Error('still offline')) .mockResolvedValueOnce('blob:retry') - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) fireEvent.click(retry) const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' }) @@ -96,7 +96,7 @@ describe('MessageImage', () => { it('clamps extreme aspect ratios and anchors the crop toward the informative edge', async () => { const load = vi.fn().mockResolvedValue('blob:ratio') const tall = render( - , + , ) const tallFrame = tall.getByRole('button', { name: 'history.png,点击查看原图' }) expect(tallFrame.getAttribute('style')).toContain('width: 60px') @@ -105,7 +105,7 @@ describe('MessageImage', () => { expect(tall.getByAltText('history.png').style.objectPosition).toBe('center top') tall.unmount() const wide = render( - , + , ) const wideFrame = wide.getByRole('button', { name: 'history.png,点击查看原图' }) expect(wideFrame.getAttribute('style')).toContain('width: 240px') @@ -114,7 +114,7 @@ describe('MessageImage', () => { expect(wide.getByAltText('history.png').style.objectPosition).toBe('left center') wide.unmount() const small = render( - , + , ) const smallFrame = small.getByRole('button', { name: 'history.png,点击查看原图' }) expect(smallFrame.getAttribute('style')).toContain('width: 100px') @@ -123,7 +123,7 @@ describe('MessageImage', () => { it('renders a tile at the fixed square without inline sizing', () => { const load = vi.fn(() => new Promise(() => {})) - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(frame.getAttribute('data-variant')).toBe('tile') expect(frame.getAttribute('style')).toBeNull() @@ -131,7 +131,7 @@ describe('MessageImage', () => { it('keeps the tile variant on the failed-load retry control', async () => { const load = vi.fn().mockRejectedValue(new Error('offline')) - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) expect(retry.getAttribute('data-variant')).toBe('tile') }) @@ -139,13 +139,13 @@ describe('MessageImage', () => { it('ignores a load settling after unmount', async () => { let resolve: ((url: string) => void) | undefined const load = vi.fn(() => new Promise((r) => { resolve = r })) - const view = render() + const view = render() view.unmount() resolve?.('blob:late') await Promise.resolve() let reject: ((error: Error) => void) | undefined const failing = vi.fn(() => new Promise((_r, rej) => { reject = rej })) - const second = render() + const second = render() second.unmount() reject?.(new Error('late failure')) await Promise.resolve() diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 0b8591b205..8cf3d2d276 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -7,7 +7,8 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { PendingSteeringBubble } from './MessageItem.tsx' +import type { ChatSnapshot } from '../contract/snapshot.ts' +import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx' import { formatRunDuration } from './message-chrome.ts' import css from './ChatView.module.css' @@ -96,6 +97,33 @@ function isFolderOpenPath(path: string): boolean { return path === '.' } +/** + * Prompt-RPC identities already rendered by durable material: user/steering + * node sources plus queue occurrences. A submission echo whose identity + * appears here is hidden in the same render, so the echo→durable swap is + * atomic — no duplicate, no gap — regardless of when the echo leaves the + * session snapshot. + */ +function observedRpcIds( + order: readonly string[], + nodes: ChatSnapshot['nodes'], + queue: readonly { readonly rpcId?: string }[], +): ReadonlySet { + const observed = new Set() + for (const key of order) { + const node = nodes.get(key) + if (node === undefined || (node.kind !== 'user' && node.kind !== 'steering')) continue + const source = (node.data as { readonly source?: unknown }).source as + | { readonly kind?: unknown; readonly rpcId?: unknown } + | undefined + if (source?.kind === 'user' && typeof source.rpcId === 'string') observed.add(source.rpcId) + } + for (const item of queue) { + if (item.rpcId !== undefined) observed.add(item.rpcId) + } + return observed +} + function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null { let latest: number | null = null for (const turn of timeline.turns.values()) { @@ -202,6 +230,15 @@ export function ChatView({ () => inbox.filter(item => item.placement === 'steering'), [inbox], ) + const pendingSubmissions = useSession(s => s.pendingSubmissions) + // Submission echoes still awaiting their durable counterpart. `order` is the + // recompute trigger: durable user material always arrives as an append, and + // every append replaces the order array. + const visibleSubmissions = useMemo(() => { + if (pendingSubmissions.length === 0) return pendingSubmissions + const observed = observedRpcIds(order, nodeStore, inbox) + return pendingSubmissions.filter(submission => !observed.has(submission.requestId)) + }, [pendingSubmissions, order, nodeStore, inbox]) const renderMessageImages = useCallback( owner => renderSlot('conversation.message.images', { ...owner, loadImage }), [loadImage, renderSlot], @@ -221,6 +258,7 @@ export function ChatView({ const openedRef = useRef(false) const lastKeyRef = useRef(null) const lastSteeringIdRef = useRef(null) + const lastSubmissionIdRef = useRef(null) /** Flow tip signature — follow-scroll only when this moves, never on a * scroll-driven at-bottom chrome re-render (which would snap inertial * scrolls the rest of the way to the floor). */ @@ -231,7 +269,8 @@ export function ChatView({ const lastKey = order.at(-1) ?? null const lastNode = lastKey === null ? undefined : nodeStore.get(lastKey) const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null - const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}` + const lastSubmissionId = visibleSubmissions[visibleSubmissions.length - 1]?.requestId ?? null + const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}:${lastSubmissionId ?? ''}` const toBottom = (el: HTMLElement): void => { anchorRef.current = null @@ -270,6 +309,7 @@ export function ChatView({ firstSeqRef.current = firstSeq lastKeyRef.current = lastKey lastSteeringIdRef.current = lastSteeringId + lastSubmissionIdRef.current = lastSubmissionId followSigRef.current = followSig return } @@ -286,6 +326,7 @@ export function ChatView({ /* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */ lastKeyRef.current = lastKey lastSteeringIdRef.current = lastSteeringId + lastSubmissionIdRef.current = lastSubmissionId followSigRef.current = followSig return } @@ -294,13 +335,15 @@ export function ChatView({ // (send lives in the composer, so arrival is detected here, not armed there). const appendedUser = lastKey !== lastKeyRef.current && lastNode?.kind === 'user' const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current + const appendedSubmission = lastSubmissionId !== null && lastSubmissionId !== lastSubmissionIdRef.current const tipMoved = followSigRef.current !== followSig lastKeyRef.current = lastKey lastSteeringIdRef.current = lastSteeringId + lastSubmissionIdRef.current = lastSubmissionId followSigRef.current = followSig // Follow new flow content while pinned; do NOT re-pin on every render // merely because atBottomRef is true (scroll threshold → setState → snap). - if (appendedUser || appendedSteering || (tipMoved && atBottomRef.current)) toBottom(el) + if (appendedUser || appendedSteering || appendedSubmission || (tipMoved && atBottomRef.current)) toBottom(el) }) const onScrollRef = useRef(() => {}) @@ -451,6 +494,14 @@ export function ChatView({ t={t} /> ))} + {visibleSubmissions.map(submission => ( + + ))} {!atBottom && (
diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index c7368dc24d..6a3e26ff69 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -1,5 +1,7 @@ import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' +import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client' +import type { MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client' import { JsonBlock, MessageText, ReferenceIcon, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts' @@ -214,7 +216,7 @@ function projectUserText(text: string, sessionLabels: readonly string[]): ReactN /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, renderMessageImages, actions, pending = false, referenceLabels = [], t, + content, renderMessageImages, actions, pending = false, referenceLabels = [], previewImages, t, }: { content: readonly unknown[] renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] @@ -224,9 +226,12 @@ function UserStyleBubble({ pending?: boolean /** Exact session mention labels associated by the adjacent recall node. */ referenceLabels?: readonly string[] + /** Local submission-echo previews replacing the content-derived image group. */ + previewImages?: readonly MessageImageSource[] t: ChatViewSlotProps['t'] }): ReactNode { - const { text, images, rest } = contentParts(content) + const { text, images: contentImages, rest } = contentParts(content) + const images = previewImages ?? contentImages const truncated = (total: number): string => t('json.truncated', { total }) const showBubble = text !== '' || rest.length > 0 return ( @@ -277,6 +282,53 @@ export function PendingSteeringBubble({ content, renderMessageImages, t }: { ) } +/** + * Render one local submission echo with the exact visual language of the + * durable user node that replaces it: draft text plus object-URL previews, + * visible from the submit click until the durable `user/message` (or its + * queue occurrence) renders. + * @param props - the session snapshot's pending submission and render seats. + * @returns the echoed user bubble. + */ +export function PendingSubmissionBubble({ submission, renderMessageImages, t }: { + submission: PendingSubmission + renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] + t: ChatViewSlotProps['t'] +}): ReactNode { + const content = useMemo( + () => (submission.text === '' ? [] : [{ type: 'text', text: submission.text }]), + [submission.text], + ) + const previewImages = useMemo( + () => submission.images.map(image => ({ + preview: { + url: image.previewUrl, + ...(image.name === undefined ? {} : { name: image.name }), + ...(image.width === undefined ? {} : { width: image.width }), + ...(image.height === undefined ? {} : { height: image.height }), + }, + })), + [submission.images], + ) + return ( + ( + + )} + /> + ) +} + /** User and admitted-steering keyed Chat renderer. */ export const UserMessageNodeView = memo(function UserMessageNodeView({ node, renderMessageImages, t, diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 1e9d71a523..094eeea06e 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -51,6 +51,7 @@ function sessionSnapshot(overrides: Partial = {}): SessionSnaps return { sessionId: SID, queue: [], + pendingSubmissions: [], running: false, removed: false, openState: 'open', diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index d4e4022e1c..cc1cf29745 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -57,6 +57,7 @@ function sessionSnapshot(): SessionSnapshot { return { sessionId: SID, queue: [], + pendingSubmissions: [], running: false, removed: false, openState: 'open', diff --git a/packages/client/ui-chat/tests/image-labels.client.spec.tsx b/packages/client/ui-chat/tests/image-labels.client.spec.tsx index bb5d339f0a..ffbde6bb29 100644 --- a/packages/client/ui-chat/tests/image-labels.client.spec.tsx +++ b/packages/client/ui-chat/tests/image-labels.client.spec.tsx @@ -29,9 +29,11 @@ function imageRenderer(calls: MessageImagesRenderOwner[]): RenderMessageImages { calls.push(owner) return (
- {owner.images.map(({ attachment: image }, index) => ( - {image.name} - ))} + {owner.images.map((entry, index) => { + if (!('attachment' in entry)) throw new Error('assistant flow images are always durable references') + const image = entry.attachment + return {image.name} + })}
) }