mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(web): 提交回显在 Chat 流尾即时渲染
ChatView 渲染 pendingSubmissions 为用户气泡,按 rpcId 对正式节点与队列行做 渲染期去重,替换原子无闪烁;新增回显跟随滚动;MessageImage/ImageGallery 增加 本地预览 arm,回显图片直接显示 object URL。
This commit is contained in:
@@ -7,6 +7,18 @@ import css from './MessageImage.module.css'
|
||||
/** Loads a session-authorized durable image URL. */
|
||||
export type ImageLoader = (attachment: ImageAttachmentRef) => Promise<string>
|
||||
|
||||
/** 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<string | null>(null)
|
||||
const preview = 'preview' in image ? image.preview : undefined
|
||||
const attachment = 'attachment' in image ? image.attachment : undefined
|
||||
const [loaded, setLoaded] = useState<string | null>(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 <button type="button" className={css.error} data-variant={variant} onClick={request}>{labels.loadFailed}</button>
|
||||
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 (
|
||||
<div className={css.gallery} data-align={align}>
|
||||
{images.map((image, index) => (
|
||||
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} variant={variant} labels={labels} />
|
||||
<MessageImage
|
||||
key={`${'attachment' in image ? image.attachment.attachmentId : image.preview.url}:${index}`}
|
||||
image={image}
|
||||
load={load}
|
||||
variant={variant}
|
||||
labels={labels}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
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<string>(() => {}))
|
||||
const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
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(<MessageImage attachment={unnamed} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage image={{ attachment: unnamed }} load={load} variant="single" labels={labels} />)
|
||||
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(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
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(
|
||||
<MessageImage attachment={{ ...attachment, width: 100, height: 2000 }} load={load} variant="single" labels={labels} />,
|
||||
<MessageImage image={{ attachment: { ...attachment, width: 100, height: 2000 } }} load={load} variant="single" labels={labels} />,
|
||||
)
|
||||
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(
|
||||
<MessageImage attachment={{ ...attachment, width: 4000, height: 100 }} load={load} variant="single" labels={labels} />,
|
||||
<MessageImage image={{ attachment: { ...attachment, width: 4000, height: 100 } }} load={load} variant="single" labels={labels} />,
|
||||
)
|
||||
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(
|
||||
<MessageImage attachment={{ ...attachment, width: 100, height: 100 }} load={load} variant="single" labels={labels} />,
|
||||
<MessageImage image={{ attachment: { ...attachment, width: 100, height: 100 } }} load={load} variant="single" labels={labels} />,
|
||||
)
|
||||
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<string>(() => {}))
|
||||
const view = render(<MessageImage attachment={attachment} load={load} variant="tile" labels={labels} />)
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant="tile" labels={labels} />)
|
||||
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(<MessageImage attachment={attachment} load={load} variant="tile" labels={labels} />)
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant="tile" labels={labels} />)
|
||||
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<string>((r) => { resolve = r }))
|
||||
const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
view.unmount()
|
||||
resolve?.('blob:late')
|
||||
await Promise.resolve()
|
||||
let reject: ((error: Error) => void) | undefined
|
||||
const failing = vi.fn(() => new Promise<string>((_r, rej) => { reject = rej }))
|
||||
const second = render(<MessageImage attachment={attachment} load={failing} variant="single" labels={labels} />)
|
||||
const second = render(<MessageImage image={{ attachment }} load={failing} variant="single" labels={labels} />)
|
||||
second.unmount()
|
||||
reject?.(new Error('late failure'))
|
||||
await Promise.resolve()
|
||||
|
||||
@@ -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<string> {
|
||||
const observed = new Set<string>()
|
||||
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<RenderMessageImages>(
|
||||
owner => renderSlot('conversation.message.images', { ...owner, loadImage }),
|
||||
[loadImage, renderSlot],
|
||||
@@ -221,6 +258,7 @@ export function ChatView({
|
||||
const openedRef = useRef(false)
|
||||
const lastKeyRef = useRef<string | null>(null)
|
||||
const lastSteeringIdRef = useRef<string | null>(null)
|
||||
const lastSubmissionIdRef = useRef<string | null>(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 => (
|
||||
<PendingSubmissionBubble
|
||||
key={submission.requestId}
|
||||
submission={submission}
|
||||
renderMessageImages={renderMessageImages}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!atBottom && (
|
||||
<div className={css.toBottomSlot}>
|
||||
|
||||
@@ -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<readonly MessageImageSource[]>(
|
||||
() => 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 (
|
||||
<UserStyleBubble
|
||||
content={content}
|
||||
previewImages={previewImages}
|
||||
renderMessageImages={renderMessageImages}
|
||||
t={t}
|
||||
actions={text => (
|
||||
<MessageIconActions
|
||||
text={text}
|
||||
time={submission.time}
|
||||
clock="start"
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** User and admitted-steering keyed Chat renderer. */
|
||||
export const UserMessageNodeView = memo(function UserMessageNodeView({
|
||||
node, renderMessageImages, t,
|
||||
|
||||
@@ -51,6 +51,7 @@ function sessionSnapshot(overrides: Partial<SessionSnapshot> = {}): SessionSnaps
|
||||
return {
|
||||
sessionId: SID,
|
||||
queue: [],
|
||||
pendingSubmissions: [],
|
||||
running: false,
|
||||
removed: false,
|
||||
openState: 'open',
|
||||
|
||||
@@ -57,6 +57,7 @@ function sessionSnapshot(): SessionSnapshot {
|
||||
return {
|
||||
sessionId: SID,
|
||||
queue: [],
|
||||
pendingSubmissions: [],
|
||||
running: false,
|
||||
removed: false,
|
||||
openState: 'open',
|
||||
|
||||
@@ -29,9 +29,11 @@ function imageRenderer(calls: MessageImagesRenderOwner[]): RenderMessageImages {
|
||||
calls.push(owner)
|
||||
return (
|
||||
<div data-testid="message-images" data-align={owner.align} data-count={owner.images.length}>
|
||||
{owner.images.map(({ attachment: image }, index) => (
|
||||
<span key={`${image.attachmentId}:${String(index)}`}>{image.name}</span>
|
||||
))}
|
||||
{owner.images.map((entry, index) => {
|
||||
if (!('attachment' in entry)) throw new Error('assistant flow images are always durable references')
|
||||
const image = entry.attachment
|
||||
return <span key={`${image.attachmentId}:${String(index)}`}>{image.name}</span>
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user