diff --git a/packages/experimental/client-ui-perf/README.i18n.yaml b/packages/experimental/client-ui-perf/README.i18n.yaml
index 4d1c44495d..18cb61aa05 100644
--- a/packages/experimental/client-ui-perf/README.i18n.yaml
+++ b/packages/experimental/client-ui-perf/README.i18n.yaml
@@ -9,8 +9,8 @@
en: 092989f386525568
zh: 4a6276cfc268c851
/deepseek-ai-dsh-experimental-client-ui-perf/summary:
- en: e9a7a32dd3ede2d8
- zh: 7eda50b8dd62ecde
+ en: 0b5de167c933df06
+ zh: eb703c1bc3f46b40
/deepseek-ai-dsh-experimental-client-ui-perf/table-of-contents:
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
@@ -24,8 +24,8 @@
en: e001601f2f9779b1
zh: 077d3703b8c82774
/deepseek-ai-dsh-experimental-client-ui-perf/understand-the-implementation:
- en: e88a036021aff9f4
- zh: 52b14b7b971278b5
+ en: 419a3c1b9360721b
+ zh: 20af2a2534ac3652
/deepseek-ai-dsh-experimental-client-ui-perf/further-exploration:
en: 285cfe1d7a99af34
zh: fbcee76e0c2f20fb
@@ -36,8 +36,8 @@
en: 7605bb868f93b4a8
zh: 184ca10ce6680499
/deepseek-ai-dsh-experimental-client-ui-perf/known-limitations-and-deferred-work:
- en: 9f52f1b4e4e75cf5
- zh: b6557b7415ce29af
+ en: f9e1b7b047937d51
+ zh: 804a52a6815823e8
/deepseek-ai-dsh-experimental-client-ui-perf/known-limitations-and-deferred-work/dev-note:
en: 3518dbaf30fb19a6
zh: c3c929308e955ebb
diff --git a/packages/experimental/client-ui-perf/README.md b/packages/experimental/client-ui-perf/README.md
index 79f31d2178..0bd215e0d3 100644
--- a/packages/experimental/client-ui-perf/README.md
+++ b/packages/experimental/client-ui-perf/README.md
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
-Adds a Performance tab beside Chat and Trajectory. Each turn is one row; expanding it lists its steps with context tokens, output tokens, cache hit rate, first-token latency, model time, output speed, tool time, and a total-time bar split into first token, generation, tools, and the rest. A running step shows elapsed time instead of a blank. Scrolling to the top pulls earlier turns through the same Session paging the other views use; there are no page controls. When a step has no tool calls, its answer label is followed by the first line of the model reply, with the full text on hover. The Question column classifies what opened the turn: a human prompt shows its text, while a team message, subagent settlement, goal round, or runtime/time context shows a localized trigger badge plus the triggering text. The panel reads Session events only; it registers no tool, prompt section, or service.
+Adds a Performance tab beside Chat and Trajectory. Each turn is one row; expanding it lists its steps with context tokens, output tokens, cache hit rate, first-token latency, model time, output speed, tool time, and a total-time bar split into first token, generation, tools, and the rest. A running step shows elapsed time instead of a blank. Scrolling to the top pulls earlier turns through the same Session paging the other views use; there are no page controls. When a step has no tool calls, its answer label is followed by the first line of the model reply, with the full text on hover. The Question column classifies what opened the turn: a human prompt shows its text, while a team message, subagent settlement, goal round, or runtime/time context shows a localized trigger badge plus the triggering text. A prompt that attached images shows an image-count pill in the same style as the trigger badge. Hovering or keyboard-focusing the pill opens a small preview strip, and clicking a preview loads that image in the shared image viewer; an image-only prompt shows the pill instead of an empty cell. The panel reads Session events and the prompt's durable images; it registers no tool, prompt section, or service.
## Table of Contents
@@ -54,8 +54,8 @@ Four Conversation Definitions fold the event window into nodes for one view targ
| `src/client/perf-fold.ts` | Event folding, tool pairing, Code-Mode dispatch attribution, and per-step metrics |
| `src/client/perf-aggregate.ts` | Turn aggregation and ordering, the interrupted-tail close, and the Code-Mode merge into the owning step |
| `src/client/perf-snapshot-builder.ts` | The `perf` view target builder |
-| `src/client/PerfView.tsx` | Scrollport with scroll-to-top paging, table, time bars, step accordion, and copy controls |
-| `src/client/locales.ts` | Every visible string, in Chinese and English |
+| `src/client/PerfView.tsx` | Scrollport with scroll-to-top paging, table, time bars, step accordion, copy controls, and the prompt image pill with its preview and viewer |
+| `src/client/locales.ts` | Every visible string, in Chinese, English, and Japanese |
Windows are bounded: when a step's `step/start` is outside the loaded window, its start-dependent columns show `—` with a `start unknown` marker rather than a guessed value. Scrolling to the top asks the Session for one older page; the view offsets the scroll position by the height it prepended, so the row being read stays in place.
@@ -91,6 +91,7 @@ No effect. The package never constructs a model request and never mutates a prom
- **Browser evidence is a test-only overlay.** Package tests cover folding, aggregation, formatting, and the jsdom table; `apps/web/tests/client-ui-perf-panel.e2e.ts` mounts the plugin through a test-only overlay over the shipped Web surface and compares a committed ARIA golden. No recorded-session scenario exists because no shipped profile mounts the package yet.
- **Late re-deliveries are not turns.** Compaction can restate a pruned tool result under its original turn and step after that turn left the window; a turn with no loaded start, prompt, or end marker is dropped instead of appearing as an empty ghost row.
- **Timing is derived, not sampled.** Total time is the last step's end minus the first step's start; queueing and scheduler waits land in the "rest" segment. The total-time bar draws tool calls as the union of their paired intervals, so parallel calls count once; the tool column keeps the summed per-call time.
+- **Prompt images load through the shared image cache.** The pill and its preview resolve durable attachments with `uiConversation.imageUrl`, so the cache owns and revokes the browser URLs per Session binding; the view creates none of its own. A failed read shows a localized failure state with retry. Touch clients do not open the hover preview; tapping the pill loads the first image directly.
### Dev Note
diff --git a/packages/experimental/client-ui-perf/README.zh.md b/packages/experimental/client-ui-perf/README.zh.md
index cb8a943c35..aee469c007 100644
--- a/packages/experimental/client-ui-perf/README.zh.md
+++ b/packages/experimental/client-ui-perf/README.zh.md
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
-在「对话」和「轨迹」旁边加一个「性能」页签。每一轮一行,展开后列出各步的上下文词数、输出词数、缓存命中率、首 token 延迟、模型用时、输出速度、工具调用用时,以及一条按首 token、生成、工具调用、其余四段拆开的总用时条。还在跑的步显示已用时间,而不是留空。页签只读会话事件,不注册工具、提示词段落或服务。
+在「对话」和「轨迹」旁边加一个「性能」页签。每一轮一行,展开后列出各步的上下文词数、输出词数、缓存命中率、首 token 延迟、模型用时、输出速度、工具调用用时,以及一条按首 token、生成、工具调用、其余四段拆开的总用时条。还在跑的步显示已用时间,而不是留空。用户问题列会说明这一轮由什么触发:人写的提问显示原文,团队消息、子代理完成、目标轮次或上下文唤醒显示本地化触发标签加触发文本。带图的提问显示与触发标签同样式的张数标签;悬停或键盘聚焦标签会浮出小图预览,点击预览用共用看图器加载该图,只有图片的提问显示标签而不是空单元格。页签只读会话事件和提问里的持久图片,不注册工具、提示词段落或服务。
## 目录
@@ -54,8 +54,8 @@ kind: "package-reference"
| `src/client/perf-fold.ts` | 事件折叠、工具配对、Code-Mode 内层调用归属、单步指标 |
| `src/client/perf-aggregate.ts` | 轮次汇总与排序,含被中断尾部的收尾 |
| `src/client/perf-snapshot-builder.ts` | `perf` 视图目标的增量 builder |
-| `src/client/PerfView.tsx` | 表格、时间条、步骤手风琴与复制按钮 |
-| `src/client/locales.ts` | 全部可见文案,中英两份 |
+| `src/client/PerfView.tsx` | 表格、时间条、步骤手风琴、复制按钮,以及提问图片标签、预览与看图器 |
+| `src/client/locales.ts` | 全部可见文案,中、英、日三份 |
会话窗口是有界的:某一步的 `step/start` 不在已加载窗口里时,与起点有关的列显示 `—` 并标「起止未知」,不估算数值。滚到顶部会向 Session 请求更早的一页;视图按新增内容的高度补偿滚动位置,正在读的行不会跳走。
@@ -91,6 +91,7 @@ kind: "package-reference"
- **浏览器证据是仅测试用的 overlay。** 包级测试覆盖折叠、汇总、格式化与 jsdom 表格;`apps/web/tests/client-ui-perf-panel.e2e.ts` 用仅测试用的 overlay 把插件挂到真实 Web 面上,并比对提交的 ARIA golden。由于还没有任何 shipped profile 挂载本包,暂无录制会话场景。
- **迟到重投不是轮次。** 压缩可能把被裁剪的工具结果按原来的轮次/步骤在之后重新投递;窗口里既没有加载到的起点、也没有问题或结束标记的轮次会被丢弃,不再显示成空的幽灵行。
- **计时是推导值。** 总用时取最后一步结束减第一步开始;排队和调度等待落在「其余」段里。总用时条把工具调用画成配对区间之并,并行调用只算一次;工具列仍显示逐次求和。
+- **提问图片走共用图片缓存。** 标签与预览用 `uiConversation.imageUrl` 解析持久附件,浏览器 URL 由缓存按 Session binding 持有并释放,视图不自己创建 URL。读取失败时显示本地化失败状态并可重试。触屏不打开悬停预览,点击标签会直接加载第一张图。
### 开发备注
diff --git a/packages/experimental/client-ui-perf/package.json b/packages/experimental/client-ui-perf/package.json
index cdffe7bc4f..20367be729 100644
--- a/packages/experimental/client-ui-perf/package.json
+++ b/packages/experimental/client-ui-perf/package.json
@@ -53,6 +53,7 @@
"@deepseek-ai/dsh-client-store": "workspace:*",
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:*",
+ "@deepseek-ai/dsh-client-ui-primitives": "workspace:*",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
"@deepseek-ai/dsh-client-ui-session": "workspace:*",
"@deepseek-ai/dsh-client-ui-slots": "workspace:*",
diff --git a/packages/experimental/client-ui-perf/src/client/PerfView.tsx b/packages/experimental/client-ui-perf/src/client/PerfView.tsx
index 94e6dac380..13688677cb 100644
--- a/packages/experimental/client-ui-perf/src/client/PerfView.tsx
+++ b/packages/experimental/client-ui-perf/src/client/PerfView.tsx
@@ -4,10 +4,12 @@
*/
import {
- Fragment, useEffect, useLayoutEffect, useRef, useState,
+ Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState,
type ReactElement, type ReactNode, type UIEvent,
} from 'react'
+import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
+import { HoverCard, ImageLightbox } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type {
@@ -32,6 +34,8 @@ const COPY_FEEDBACK_MS = 1_200
const CODE_LINE_LIMIT = 400
/** Line count above which a diff falls back to whole-side removal and addition. */
const DIFF_LINE_LIMIT = 300
+/** Hover dwell before the prompt image preview opens. */
+const IMAGE_PREVIEW_DELAY_MS = 200
/** Numeric columns in table order. */
const COLUMNS = ['context', 'output', 'cache', 'ttft', 'model', 'tps', 'tool'] as const
@@ -89,12 +93,33 @@ export interface PerfViewInjected {
* @returns false when the snapshot produced no new page.
*/
loadOlder: () => Promise
+ /**
+ * Resolve one durable prompt image through the shared Conversation image cache.
+ * @param attachment - image reference the user message recorded.
+ * @returns browser URL owned by the cache for the Session binding's lifetime.
+ */
+ loadImage: (attachment: ImageAttachmentRef) => Promise
}
/** Full props of the performance conversation View. */
export type PerfViewProps =
ConvViewProps & InjectFace & PropsLocale<'perf'>
+/** Resolve one durable prompt image to a browser URL. */
+type LoadImage = PerfViewInjected['loadImage']
+
+/** Prompt image shown by the full-size lightbox. */
+interface LightboxImage {
+ readonly src: string
+ readonly alt: string
+}
+
+/** Load state of one preview thumbnail. */
+type ImageLoad =
+ | { readonly status: 'loading' }
+ | { readonly status: 'failed' }
+ | { readonly status: 'ready'; readonly src: string }
+
/** Open/closed default for turns the user never toggled explicitly. */
type ViewMode = 'newest' | 'all' | 'none'
@@ -195,6 +220,7 @@ interface CallGroup {
interface TurnRowsOptions {
readonly turn: PerfTurnMetrics
readonly t: PerfTranslate
+ readonly loadImage: LoadImage
readonly now: number
readonly open: boolean
readonly state: StepViewState
@@ -267,6 +293,25 @@ interface PromptCellProps {
readonly turn: PerfTurnMetrics
readonly t: PerfTranslate
readonly className: string | undefined
+ readonly loadImage: LoadImage
+}
+
+/** Props of one preview thumbnail inside the image popover. */
+interface PromptImageThumbProps {
+ readonly attachment: ImageAttachmentRef
+ readonly index: number
+ readonly count: number
+ readonly loadImage: LoadImage
+ readonly t: PerfTranslate
+ readonly onOpen: (image: LightboxImage) => void
+}
+
+/** Props of the image-count pill and its hover/focus preview. */
+interface ImagePromptPillProps {
+ readonly first: ImageAttachmentRef
+ readonly images: readonly ImageAttachmentRef[]
+ readonly loadImage: LoadImage
+ readonly t: PerfTranslate
}
/** Empty per-turn step state, shared so unexpanded turns allocate nothing. */
@@ -908,8 +953,146 @@ function TimeCell({ value, scale, gap, className, trackClass, now, t }: TimeCell
)
}
+/** Display name of one prompt image: its recorded name, otherwise its position. */
+function imageName(attachment: ImageAttachmentRef, index: number, count: number, t: PerfTranslate): string {
+ return attachment.name ?? t('imagePreview.alt', { index, count })
+}
+
+/** One prompt-image thumbnail: loads while the preview is open and opens the full image on click. */
+function PromptImageThumb({
+ attachment, index, count, loadImage, t, onOpen,
+}: PromptImageThumbProps): ReactElement {
+ const [image, setImage] = useState({ status: 'loading' })
+ const [attempt, setAttempt] = useState(0)
+ useEffect(() => {
+ let live = true
+ void loadImage(attachment).then(
+ (src) => { if (live) setImage({ status: 'ready', src }) },
+ () => { if (live) setImage({ status: 'failed' }) },
+ )
+ return () => { live = false }
+ }, [attachment, loadImage, attempt])
+ if (image.status === 'ready') {
+ const name = imageName(attachment, index, count, t)
+ return (
+
+ )
+ }
+ return (
+
+ {image.status === 'loading' ? t('imagePreview.loading') : t('imagePreview.failed')}
+ {image.status === 'failed' ? (
+
+ ) : null}
+
+ )
+}
+
+/**
+ * Image-count pill for one turn's prompt: hovering or focusing it previews the
+ * images, and clicking it loads the first one full size. URLs come from the
+ * shared Conversation image cache, which owns their lifetime per Session.
+ */
+function ImagePromptPill({ first, images, loadImage, t }: ImagePromptPillProps): ReactElement {
+ const [lightbox, setLightbox] = useState(null)
+ const [status, setStatus] = useState<'idle' | 'loading' | 'failed'>('idle')
+ const count = images.length
+ const closeLightbox = useCallback(() => { setLightbox(null) }, [])
+ const label = status === 'idle'
+ ? t('turn.images', { count })
+ : status === 'loading' ? t('imagePreview.loading') : t('imagePreview.failed')
+ const preview = (
+
+ {images.map((attachment, offset) => (
+
+ ))}
+
+ )
+ return (
+
+ {
+ // The row toggles its steps on click; the pill opens the image instead.
+ event.stopPropagation()
+ setStatus('loading')
+ void loadImage(first).then(
+ (src) => {
+ setStatus('idle')
+ setLightbox({ src, alt: imageName(first, 1, count, t) })
+ },
+ () => { setStatus('failed') },
+ )
+ }}
+ >
+ {label}
+
+ )}
+ content={preview}
+ />
+ {lightbox === null ? null : (
+ { event.stopPropagation() }}>
+
+
+ )}
+
+ )
+}
+
/** Prompt column: the turn's user question, clipped with the full text in the title. */
-function PromptCell({ turn, t, className }: PromptCellProps): ReactElement {
+function PromptCell({ turn, t, className, loadImage }: PromptCellProps): ReactElement {
+ const images = turn.promptImages
+ const first = images[0]
+ const pill = first === undefined
+ ? null
+ :
const source = turn.promptSource
if (source !== null && source !== 'user') {
// Automated turns explain what woke them instead of pretending to be a question.
@@ -918,27 +1101,24 @@ function PromptCell({ turn, t, className }: PromptCellProps): ReactElement {
return (
{label}
+ {pill}
{text === '' ? null : {text}}
)
}
if (turn.prompt === null || turn.prompt === '') {
// An image-only prompt still says how many images opened the turn.
- if (turn.promptImages > 0) {
- const label = t('turn.images', { count: turn.promptImages })
- return (
-