Merge remote-tracking branch 'origin/master' into fix/web-transcript-projection
# Conflicts: # apps/web/tests/queue-actions.e2e.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/chat/MessageItem.tsx
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 7552e198190a2736092299953dfff61be9a07147
|
||||
README.zh.md: 7af337c03305d1da7019869befdd9cbf65584eac
|
||||
README.md: 7752eb98a5e279e541eecb3ef4e7d1ac46729a48
|
||||
README.zh.md: ca72222e2f0b8aa69c11a4bbf82d862222f46fa4
|
||||
|
||||
@@ -50,6 +50,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
|
||||
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
|
||||
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch forks through the turn containing that message, increments the inherited title on the client, and then opens the child, while a fork or rename failure leaves the source selected.
|
||||
- **Sent user messages cannot be edited** — the user bubble's IconActions row carries clock / copy / branch only, and branching from the message is the nearest gesture. The control returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)).
|
||||
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
|
||||
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
|
||||
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
|
||||
|
||||
@@ -50,6 +50,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
||||
- **统计行的耗时只覆盖窗口内消息流**:LLM(大语言模型)与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
|
||||
- **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
|
||||
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。分支会 fork 到包含该消息的轮次末尾,在 client 端递增继承标题后打开子会话,而 fork 或改名失败时源会话保持选中。
|
||||
- **已发送的 user 消息无法编辑**:user 气泡的 IconActions 行只有时钟/复制/分支,从该消息分支是最接近的手势。该控件要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。
|
||||
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
|
||||
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
|
||||
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
|
||||
|
||||
@@ -20,6 +20,7 @@ import { InputBar } from './skeleton/InputBar.tsx'
|
||||
import { ChatView } from './chat/ChatView.tsx'
|
||||
import { StatsLine } from './chat/StatsLine.tsx'
|
||||
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
|
||||
import { readToolview } from './toolviews/read-row.tsx'
|
||||
import { fileMutationToolview } from './toolviews/file-mutation-row.tsx'
|
||||
import { webToolview } from './toolviews/web-row.tsx'
|
||||
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
|
||||
@@ -320,6 +321,10 @@ export function apply(ctx: Context): void {
|
||||
// (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
|
||||
ctx.plugin(bashToolviewSample)
|
||||
|
||||
// The read row rides the same seam (a product registration, not a sample):
|
||||
// Read · {path} chrome with the file's read card resident below it.
|
||||
ctx.plugin(readToolview)
|
||||
|
||||
// The write/edit rows ride the same seam: a file-mutation call declares the
|
||||
// diff render intent, so these rows stack the applied diff card under their
|
||||
// path-link summary (the terminal card's posture, applied to diffs).
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* The generic card grows a resident web card under its summary row when the
|
||||
tool declares the `web` render intent but has no keyed row of its own (the
|
||||
web_search/web_fetch rows register their own WebRow). A column around the
|
||||
ToolRow keeps the row's own 24px height. */
|
||||
/* GenericToolCard resident cards: a read-declaring or web-declaring tool
|
||||
without its own keyed row (e.g. web_fetch) grows a resident card under its
|
||||
summary row. A column around the ToolRow keeps the row's own 24px height, so
|
||||
the read card renders identically to the keyed ReadRow and the web card to
|
||||
the web_search/web_fetch WebRow. */
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
@@ -10,6 +11,7 @@
|
||||
|
||||
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
|
||||
and replaces the primitive's standalone vertical margin with the flow's. */
|
||||
.read,
|
||||
.web {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
|
||||
IconThinkOutline14, WebBlock,
|
||||
IconThinkOutline14, ReadBlock, WebBlock,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.ts'
|
||||
import { diffCardModel } from '../contract/diff-card-model.ts'
|
||||
import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
|
||||
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
|
||||
@@ -37,6 +38,7 @@ export interface GenericToolCardProps extends ToolRowOwnerProps {
|
||||
export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const terminal = terminalCardModel(block, cwd)
|
||||
const read = readCardModel(block, cwd)
|
||||
const diff = diffCardModel(block)
|
||||
const web = webCardModel(block)
|
||||
// A failing exit status is the terminal card's own error signal (the call
|
||||
@@ -69,6 +71,18 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
// A read-declaring tool without its own keyed row lands here (e.g. web_fetch),
|
||||
// so the file's read card is resident below the summary row exactly as the
|
||||
// keyed ReadRow draws it. Only wrap when a card is present, so every other
|
||||
// tool keeps the bare ToolRow.
|
||||
if (read !== null) {
|
||||
return (
|
||||
<div className={css.card}>
|
||||
{row}
|
||||
<ReadBlock {...read} maxLines={CHAT_READ_MAX_LINES} className={css.read} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// A web-declaring tool without its own keyed row lands here; its card is
|
||||
// resident under the summary, mirroring WebRow (and BashRow's terminal card).
|
||||
if (web === null) return row
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// Shared IconActions chrome for user and assistant messages: copy live,
|
||||
// branch wired through onBranch, date-aware clock,
|
||||
// optional edit stub.
|
||||
// branch wired through onBranch, date-aware clock.
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
IconBranchOutline16, IconCopyOutline16, IconEditOutline16, Tooltip,
|
||||
IconBranchOutline16, IconCopyOutline16, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { formatMessageClock, writeClipboard } from './message-chrome.ts'
|
||||
@@ -18,8 +17,6 @@ export interface MessageIconActionsProps {
|
||||
time: number
|
||||
/** Clock before icons (user) or after (assistant). */
|
||||
clock: 'start' | 'end'
|
||||
/** When true, append the stub edit control (user bubble). */
|
||||
edit?: boolean | undefined
|
||||
/** Fork the session at this message. */
|
||||
onBranch?: (() => void) | undefined
|
||||
/** Parent layout class composed onto the actions row. */
|
||||
@@ -30,11 +27,11 @@ export interface MessageIconActionsProps {
|
||||
|
||||
/**
|
||||
* Copy / branch (/ clock) IconActions row shared by user and assistant chrome.
|
||||
* @param props - Copy text, event time, clock side, optional edit, branch callback, className.
|
||||
* @param props - Copy text, event time, clock side, branch callback, className.
|
||||
* @returns The actions row element.
|
||||
*/
|
||||
export function MessageIconActions({
|
||||
text, time, clock, edit, onBranch, className, t,
|
||||
text, time, clock, onBranch, className, t,
|
||||
}: MessageIconActionsProps) {
|
||||
const day = useCalendarDay()
|
||||
const onCopy = useCallback(() => {
|
||||
@@ -58,13 +55,6 @@ export function MessageIconActions({
|
||||
<IconBranchOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{edit === true && (
|
||||
<Tooltip label={t('edit')} side="bottom">
|
||||
<button type="button" className={css.action} aria-label={t('edit')}>
|
||||
<IconEditOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{clock === 'end' ? clockEl : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// MessageItem: simple chat nodes — user bubble (right-aligned, with
|
||||
// clock + copy / branch / edit IconActions), steering (badged bubble), context
|
||||
// clock + copy / branch IconActions), steering (badged bubble), context
|
||||
// injection, compaction marker, retry disclosure, and unknown-surface JSON rows.
|
||||
|
||||
import { memo, useEffect, useMemo, useState } from 'react'
|
||||
@@ -158,7 +158,6 @@ export const MessageItem = memo(function MessageItem({
|
||||
text={text}
|
||||
time={node.time}
|
||||
clock="start"
|
||||
edit
|
||||
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
|
||||
className={css.actions}
|
||||
t={t}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Pure derivation of the read-card props from a frozen call slice: the
|
||||
* `card:'read'` render intent the read tool declares arrives on the snapshot as
|
||||
* the settled result node's `resultView`, and this is the one place that turns
|
||||
* it into what {@link ReadBlock} draws. Both conversation render sites (the chat
|
||||
* tool row's resident body and the details panel's Output section) call this, so
|
||||
* the path, lines, total, and language they show are derived once.
|
||||
*
|
||||
* The read card is result-side only ([read card note](../../../../../../.agents/notes/implemented/feature/2026-07-30-web-read-card.md)):
|
||||
* a call carries no file content until `execute` returns, so the pending call
|
||||
* stays a generic card (`kind: 'read'`). A running read therefore has no read
|
||||
* card, and this returns null for it — the row keeps its args-derived summary
|
||||
* until the result arrives.
|
||||
* @module
|
||||
*/
|
||||
import type { ReadBlockLine, ReadBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts'
|
||||
|
||||
/**
|
||||
* Content lines the chat row's resident read body shows before collapsing the
|
||||
* middle — half the primitive's own default, which the details panel keeps. A
|
||||
* chat row is a summary surface inside the message flow: the flow must stay
|
||||
* scannable across many calls, while the details panel is the single-call
|
||||
* reading surface. A design constant of this UI's row geometry, not a
|
||||
* deployment choice, so it is fixed here rather than a plugin Config field. The
|
||||
* same split [`CHAT_TERMINAL_MAX_LINES`](./terminal-card-model.ts) draws for
|
||||
* terminal output.
|
||||
*/
|
||||
export const CHAT_READ_MAX_LINES = 8
|
||||
|
||||
/**
|
||||
* The {@link ReadBlock} props this derivation owns. Picked off the primitive's
|
||||
* props so the two stay in step; `maxLines`/`className` belong to each render
|
||||
* site.
|
||||
*/
|
||||
export type ReadCardModel = Pick<ReadBlockProps, 'label' | 'lines' | 'totalLines' | 'lang'>
|
||||
|
||||
/**
|
||||
* Derive the read-card props for a tool call, or null when this call is not a
|
||||
* read card and belongs on the generic path.
|
||||
*
|
||||
* The read card is result-side only, so only a settled call whose result view
|
||||
* declares `card:'read'` produces one. Every other case is null — the
|
||||
* documented generic-card default:
|
||||
*
|
||||
* - A running call: it has no result view yet, and a read carries no content at
|
||||
* call time.
|
||||
* - A settled call whose result view is not a read card — including a `card`
|
||||
* value this UI version does not know, which arrives over the wire and cannot
|
||||
* be trusted to be one of the compiled variants, and the read tool's own
|
||||
* generic fallback for an error result or a non-envelope body.
|
||||
*
|
||||
* The label is the read view's `title` when the tool supplied one (the
|
||||
* presentation contract's replacement-title rule), otherwise the file path
|
||||
* relativized to the session workspace so a workspace-rooted absolute path
|
||||
* displays the same short form the row summary shows.
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* @param sessionCwd - the session workspace root; a workspace-rooted absolute
|
||||
* path label displays relative to it. Absent leaves the path as authored.
|
||||
* @returns the read-card props, or null for the generic path.
|
||||
*/
|
||||
export function readCardModel(block: ToolCallBlock, sessionCwd?: string): ReadCardModel | null {
|
||||
// Running has no result view; a read carries no content until execute returns.
|
||||
if (!('kind' in block)) return null
|
||||
const result = block.resultView?.card === 'read' ? block.resultView : null
|
||||
if (result === null) return null
|
||||
// Lines arrive frozen off the snapshot; copy into the primitive's own line
|
||||
// shape so the card never holds a reference into the runtime's cache.
|
||||
const lines: ReadBlockLine[] = result.lines.map(line => ({ number: line.number, text: line.text }))
|
||||
return {
|
||||
label: result.title ?? relativizeToCwd(result.path, sessionCwd),
|
||||
lines,
|
||||
totalLines: result.totalLines,
|
||||
lang: result.lang,
|
||||
}
|
||||
}
|
||||
@@ -133,8 +133,13 @@ const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
|
||||
others: [],
|
||||
}
|
||||
|
||||
/** Strip the workspace root from workspace-rooted absolute paths (display only). */
|
||||
function relativizeToCwd(text: string, cwd: string | undefined): string {
|
||||
/**
|
||||
* Strip the workspace root from a workspace-rooted absolute path (display only).
|
||||
* @param text - the path to shorten.
|
||||
* @param cwd - session workspace root; absent or empty leaves the path unchanged.
|
||||
* @returns the path relative to the workspace root, or unchanged when it is not rooted there.
|
||||
*/
|
||||
export function relativizeToCwd(text: string, cwd: string | undefined): string {
|
||||
if (cwd === undefined || cwd === '') return text
|
||||
const root = cwd.replace(/[/\\]+$/, '')
|
||||
if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1)
|
||||
|
||||
@@ -108,8 +108,9 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Same rule for the web card: it sits under the section label, so the section
|
||||
owns the spacing rather than the primitive's own vertical margin. */
|
||||
/* The read and web cards sit directly under their section label, same as the
|
||||
terminal card: drop the primitive's standalone vertical margin. */
|
||||
.read,
|
||||
.web {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
// share the store seat exists for) and derives the call material from the
|
||||
// session snapshot — no data of its own.
|
||||
|
||||
import { CodeBlock, DiffBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CodeBlock, DiffBlock, ReadBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DetailsSlotProps } from '../contract/slots.ts'
|
||||
import { readCardModel } from '../contract/read-card-model.ts'
|
||||
import { diffCardModel } from '../contract/diff-card-model.ts'
|
||||
import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { webCardModel } from '../contract/web-card-model.ts'
|
||||
@@ -129,7 +130,9 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
|
||||
* The Output section's body for the selected call. A terminal-card call — a
|
||||
* shell command's call/result views — renders through the shared TerminalBlock
|
||||
* at the primitive's own full height allowance, so column-aligned output keeps
|
||||
* its alignment and scrolls sideways instead of folding. A diff-card call — a
|
||||
* its alignment and scrolls sideways instead of folding. A read-card call
|
||||
* renders through the shared ReadBlock at that same full height, so the whole
|
||||
* returned window is line-numbered and highlighted. A diff-card call — a
|
||||
* write/edit's applied change — renders through the shared DiffBlock at the same
|
||||
* full height. A web-card call — a `web_search`/`web_fetch` result — renders
|
||||
* through WebBlock at its own full source-list allowance. Every other call, and
|
||||
@@ -153,6 +156,10 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
|
||||
</>
|
||||
)
|
||||
}
|
||||
const read = readCardModel(material.block, cwd)
|
||||
// The panel takes the primitive's own default cap, not the row's tighter one:
|
||||
// it is the single-call reading surface, so the whole window is available.
|
||||
if (read !== null) return <ReadBlock {...read} className={css.read} />
|
||||
const diff = diffCardModel(material.block)
|
||||
if (diff !== null) return <DiffBlock {...diff.card} className={css.cardBody} />
|
||||
const web = webCardModel(material.block)
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/* Read toolview: same geometry/tokens as ToolRow (figma Read · {path}), plus
|
||||
the read card the row stacks under its summary line. */
|
||||
|
||||
/* Summary line over the read card; the summary row keeps its own 24px height,
|
||||
so the card is a column around it rather than a change to it. */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
|
||||
and replaces the primitive's standalone vertical margin with the flow's. */
|
||||
.read {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative; /* sweep-glare overlay anchor */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Running sweep glare — same pattern as BashRow/ToolRow, so a running read row
|
||||
gives the same executing feedback a running command row does. The leading
|
||||
read icon stays static (a read has no per-step state to animate); the sweep
|
||||
is the row-level running signal. */
|
||||
.root[data-state='running']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: dsh-read-row-sweep 2.6s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes dsh-read-row-sweep {
|
||||
0% { left: -300px; }
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
.leading {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.sep {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
margin: 0 8px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.summary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* File path: same geometry as .summary; hover underline + pointer. */
|
||||
.fileLink {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fileLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Read toolview registrant: the keyed toolview hole for the read tool
|
||||
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
|
||||
// Product chrome matches ToolRow (figma: Read · {path}); the summary is the
|
||||
// file path as an openable link, exactly as the generic read row draws it.
|
||||
//
|
||||
// A read RESULT declares the read render intent, so this row renders the file's
|
||||
// own line-numbered, syntax-highlighted content through ReadBlock resident
|
||||
// below its summary line — the same posture BashRow gives a terminal card. The
|
||||
// card is capped at CHAT_READ_MAX_LINES (the chat flow's tighter cap over the
|
||||
// block's own default of 16) with the block's internal expander keeping a long
|
||||
// read from taking over the message flow. A running read (no result yet) and a
|
||||
// non-read result both render the summary row alone. The read intent is
|
||||
// result-side only, so there is no running-state read card to draw.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { IconBrowseOutline16, ReadBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import css from './read-row.module.css'
|
||||
|
||||
/** Leading-slot state substitution: the tool icon yields to the state dot
|
||||
* (error = red, interrupted = amber). Running keeps the icon. */
|
||||
function leadingFor(state: ToolRowState) {
|
||||
switch (state) {
|
||||
case 'error': return <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
default: return <IconBrowseOutline16 size={14} />
|
||||
}
|
||||
}
|
||||
|
||||
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
|
||||
function stateStatus(state: ToolRowState): string | null {
|
||||
switch (state) {
|
||||
case 'running': return '运行中'
|
||||
case 'error': return '失败'
|
||||
case 'stopped': return '已停止'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read row: icon + Read · {path} in the shared ToolRow chrome, with the file's
|
||||
* read card resident below it. The summary path is an openable host link when
|
||||
* the row names a single file; the card's copy and expand controls plus that
|
||||
* link are the row's only interactions (tool rows are not details-panel
|
||||
* targets).
|
||||
*/
|
||||
export function ReadRow({ toolName, block, sessionId, useSessions, openFile }: ToolRowProps) {
|
||||
// Session workspace root: the read view's path relativizes against it (a
|
||||
// workspace-rooted absolute path shows its short form), which the pure
|
||||
// presenter cannot do.
|
||||
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const read = readCardModel(block, cwd)
|
||||
const status = stateStatus(model.state)
|
||||
const filePath = model.filePath
|
||||
return (
|
||||
<div className={css.card}>
|
||||
{/* jscpd:ignore-start — the summary-line chrome (leading, status, title,
|
||||
sep, path-link/summary) is the shared ToolRow row shape every keyed
|
||||
toolview draws; extracting it into one component is a separate change
|
||||
tracked for all rows at once, not this read-card PR. */}
|
||||
<div className={css.root} data-variant="read" data-state={model.state}>
|
||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
<span className={css.title}>{model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
{filePath !== undefined ? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.fileLink}
|
||||
onClick={() => { openFile(filePath) }}
|
||||
>
|
||||
{model.summary}
|
||||
</button>
|
||||
) : (
|
||||
<span className={css.summary}>{model.summary}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* jscpd:ignore-end */}
|
||||
{read !== null && (
|
||||
<ReadBlock {...read} maxLines={CHAT_READ_MAX_LINES} className={css.read} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The read row as a plain registrant plugin. `inject` carries the load-order
|
||||
* seam: requiring the conversation service guarantees the chat entry (and with
|
||||
* it the 'conversation.chat.toolview' declaration) is registered —
|
||||
* ui-conversation's apply mounts the service after the chat entry.
|
||||
*/
|
||||
export const readToolview = {
|
||||
name: 'read-toolview',
|
||||
inject: ['slots', 'conversation'],
|
||||
/**
|
||||
* Register the read row into the chat view's keyed toolview hole.
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read' }, ReadRow)
|
||||
},
|
||||
}
|
||||
@@ -84,14 +84,14 @@ describe('apply wiring', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('mounts the bash sample, the file-mutation rows, the web rows, and the product rows as keyed entries through the load-order seam', async () => {
|
||||
it('mounts the bash sample, the read row, the file-mutation rows, the web rows, and the product rows as keyed entries through the load-order seam', async () => {
|
||||
const b = await bench()
|
||||
// Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
|
||||
// service being present implies the chat entry declared the hole first. The
|
||||
// file-mutation registrant claims both write and edit for the diff card; the
|
||||
// web rows register one component under both web tool names.
|
||||
const entries = b.slots.entries('conversation.chat.toolview')
|
||||
expect(entries.map(e => e.options.key)).toEqual(['bash', 'edit', 'write', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
|
||||
expect(entries.map(e => e.options.key)).toEqual(['bash', 'read', 'edit', 'write', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
|
||||
// Stats stick with the composer (not inside ChatView).
|
||||
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
|
||||
await b.runtime.dispose()
|
||||
|
||||
@@ -27,7 +27,7 @@ afterEach(() => {
|
||||
const t: MessageItemProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
describe('MessageItem arms', () => {
|
||||
it('user bubbles expose clock / copy / branch / edit; copy writes the text', () => {
|
||||
it('user bubbles expose clock / copy / branch and no edit; copy writes the text', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
@@ -47,7 +47,7 @@ describe('MessageItem arms', () => {
|
||||
expect(screen.getByText('14:24')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '编辑' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: '编辑' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('hello bubble')
|
||||
})
|
||||
|
||||
293
packages/client/ui-conversation/tests/read-card.spec.tsx
Normal file
293
packages/client/ui-conversation/tests/read-card.spec.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
// @vitest-environment jsdom
|
||||
// The read render intent on the web side: the pure readCardModel derivation
|
||||
// over the settled result view, and both conversation render sites that consume
|
||||
// it — the chat tool row (the keyed ReadRow and the GenericToolCard fallback,
|
||||
// each with the read card resident under the summary) and the details panel's
|
||||
// Output section. Also pins the keyed 'read' toolview registration.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { Context } from 'cordis'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type {
|
||||
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SelectionTarget, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/contract/read-card-model.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { ReadRow, readToolview } from '../src/client/toolviews/read-row.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
/** The chat-view locale seat: this package's namespace over the common fallback. */
|
||||
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
// The read tool's real schema key is `file_path`; the top-level read samples
|
||||
// use it so the row exercises a production-shaped call. `web_fetch` (below) has
|
||||
// its own schema whose key is not `file_path`, so it keeps a `url`-less `path`.
|
||||
const ARGS = '{"file_path":"src/a.ts","offset":41}'
|
||||
const WEB_FETCH_ARGS = '{"path":"src/a.ts","offset":41}'
|
||||
|
||||
/** The read block's rendered content cells, one string per row (highlighting
|
||||
* breaks a line across token spans, so match on the row's textContent). */
|
||||
function contentTexts(container: HTMLElement): string[] {
|
||||
return [...container.querySelectorAll('[data-read] [class^="_content_"]')].map(cell => cell.textContent ?? '')
|
||||
}
|
||||
|
||||
/** Three windowed lines starting at file line 41 (a read past an offset). */
|
||||
const sampleLines = [
|
||||
{ number: 41, text: 'export const a = 1' },
|
||||
{ number: 42, text: 'export const b = 2' },
|
||||
{ number: 43, text: 'export const c = 3' },
|
||||
]
|
||||
|
||||
/** The read tool's own result view for a settled file read. */
|
||||
const resultRead = (over?: Partial<Extract<ToolResultView, { card: 'read' }>>): ToolResultView => ({
|
||||
card: 'read', path: 'src/a.ts', offset: 41, lines: sampleLines, totalLines: 180, lang: 'ts', ...over,
|
||||
})
|
||||
|
||||
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
|
||||
callId: 'c1', name: 'read', argsRaw: ARGS,
|
||||
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, ...over,
|
||||
})
|
||||
|
||||
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
|
||||
call: { name: 'read', argsRaw: ARGS },
|
||||
callTime: 1_000,
|
||||
content: [{ type: 'text', text: '41: export const a = 1' }], isError: false,
|
||||
callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, resultView: resultRead(), ...over,
|
||||
})
|
||||
|
||||
describe('readCardModel', () => {
|
||||
it('derives the card from a settled read result view', () => {
|
||||
expect(readCardModel(settled())).toEqual({
|
||||
label: 'src/a.ts', lines: sampleLines, totalLines: 180, lang: 'ts',
|
||||
})
|
||||
})
|
||||
|
||||
it('copies the lines into the primitive shape rather than aliasing the frozen slice', () => {
|
||||
const model = readCardModel(settled())
|
||||
expect(model?.lines).toEqual(sampleLines)
|
||||
expect(model?.lines).not.toBe(sampleLines)
|
||||
expect(model?.lines[0]).not.toBe(sampleLines[0])
|
||||
})
|
||||
|
||||
it('takes the result view\'s replacement title over the relativized path', () => {
|
||||
// The presentation contract defines a result title as REPLACING the pending
|
||||
// one, so a tool that supplies a label wins over the path here.
|
||||
expect(readCardModel(settled({ resultView: resultRead({ title: 'Read (head) src/a.ts' }) }))?.label)
|
||||
.toBe('Read (head) src/a.ts')
|
||||
})
|
||||
|
||||
it('relativizes a workspace-rooted path label, and leaves others as authored', () => {
|
||||
// A workspace-rooted absolute path shows its short form.
|
||||
expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }), '/w/app')?.label)
|
||||
.toBe('src/a.ts')
|
||||
// A path outside the workspace stays as authored.
|
||||
expect(readCardModel(settled({ resultView: resultRead({ path: '/srv/other.ts' }) }), '/w/app')?.label)
|
||||
.toBe('/srv/other.ts')
|
||||
// With no session cwd there is nothing to relativize against.
|
||||
expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }))?.label)
|
||||
.toBe('/w/app/src/a.ts')
|
||||
})
|
||||
|
||||
it('carries an omitted language through as undefined', () => {
|
||||
const noLang = resultRead()
|
||||
delete (noLang as { lang?: string }).lang
|
||||
expect(readCardModel(settled({ resultView: noLang }))?.lang).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns null for a running read: the read intent is result-side only', () => {
|
||||
// A read carries no content until execute returns, so the pending call is a
|
||||
// generic card and there is no read card to draw yet.
|
||||
expect(readCardModel(running())).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for every non-read settled call: no view, generic view, unknown card', () => {
|
||||
expect(readCardModel(settled({ resultView: null }))).toBeNull()
|
||||
expect(readCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull()
|
||||
// A card tag this UI version does not know arrives over the wire; the
|
||||
// documented generic-card default takes it, not a crash.
|
||||
const future = { card: 'chart' } as unknown as ToolResultView
|
||||
expect(readCardModel(settled({ resultView: future }))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GenericToolCard read body', () => {
|
||||
const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
|
||||
callId: 'c1', toolName: 'web_fetch', block, openFile: vi.fn(), t,
|
||||
})
|
||||
|
||||
it('renders the read card resident under the summary, capped tighter than the panel', () => {
|
||||
expect(CHAT_READ_MAX_LINES).toBeLessThan(16)
|
||||
// web_fetch lands on the read variant without its own keyed row, so the
|
||||
// fallback card owns the resident read block.
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({ call: { name: 'web_fetch', argsRaw: WEB_FETCH_ARGS } }))} />)
|
||||
expect(view.container.querySelector('[data-read]')).not.toBeNull()
|
||||
expect(contentTexts(view.container)).toContain('export const a = 1')
|
||||
// The gutter keeps the file's own line numbers.
|
||||
expect(view.getByText('41')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a non-read tool renders the bare row with no read card', () => {
|
||||
const view = render(<GenericToolCard {...({
|
||||
callId: 'c1', toolName: 'echo', block: settled({
|
||||
call: { name: 'echo', argsRaw: '{"text":"x"}' }, callView: null, resultView: null,
|
||||
}), openFile: vi.fn(), t,
|
||||
})} />)
|
||||
expect(view.container.querySelector('[data-read]')).toBeNull()
|
||||
})
|
||||
|
||||
it('a running read renders the summary row alone (no result view yet)', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(running({ name: 'web_fetch' }))} />)
|
||||
expect(view.container.querySelector('[data-read]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReadRow keyed toolview', () => {
|
||||
const list = () => createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
})
|
||||
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode): ToolRowProps => ({
|
||||
callId: 'c1', toolName: 'read', block, openFile: vi.fn(),
|
||||
sessionId: SID, useSessions: bindSnapshotSelector(list()),
|
||||
} as unknown as ToolRowProps)
|
||||
|
||||
it('renders the file path summary and the resident read card', () => {
|
||||
const view = render(<ReadRow {...rowProps(settled())} />)
|
||||
expect(view.getByText('Read')).toBeTruthy()
|
||||
// The path appears twice: the row summary link and the card's banner label.
|
||||
expect(view.getAllByText('src/a.ts').length).toBe(2)
|
||||
expect(view.container.querySelector('[data-read]')).not.toBeNull()
|
||||
expect(contentTexts(view.container)).toContain('export const a = 1')
|
||||
expect(view.getByText('显示 3 / 180 行')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('the path summary opens the file through the host', () => {
|
||||
const openFile = vi.fn()
|
||||
const view = render(<ReadRow {...{ ...rowProps(settled()), openFile }} />)
|
||||
fireEvent.click(view.getByRole('button', { name: 'src/a.ts' }))
|
||||
// The row derives the file path from args; the chat view resolves it against
|
||||
// the cwd before this callback opens it, so the arg path is what arrives.
|
||||
expect(openFile).toHaveBeenCalledWith('src/a.ts')
|
||||
})
|
||||
|
||||
it('a running read renders the summary row alone, and its state', () => {
|
||||
const view = render(<ReadRow {...rowProps(running())} />)
|
||||
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('running')
|
||||
expect(view.container.querySelector('[data-read]')).toBeNull()
|
||||
})
|
||||
|
||||
it('an error read result shows the error state and no read card', () => {
|
||||
const view = render(<ReadRow {...rowProps(settled({
|
||||
resultView: { card: 'generic' }, isError: true,
|
||||
content: [{ type: 'text', text: 'ENOENT' }],
|
||||
}))} />)
|
||||
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('error')
|
||||
expect(view.container.querySelector('[data-read]')).toBeNull()
|
||||
})
|
||||
|
||||
it('an interrupted read shows the stopped state', () => {
|
||||
const view = render(<ReadRow {...rowProps(settled({
|
||||
resultView: null, isError: true, error: { name: 'ToolError', code: 'interrupted' },
|
||||
}))} />)
|
||||
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('stopped')
|
||||
})
|
||||
|
||||
it('registers under the read key of the keyed toolview slot', () => {
|
||||
const registered: { name: unknown; key?: unknown }[] = []
|
||||
const ctx = { slots: { register: (options: { name: unknown; key?: unknown }) => { registered.push(options) } } } as unknown as Context
|
||||
readToolview.apply(ctx)
|
||||
expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read' }])
|
||||
expect(readToolview.inject).toContain('conversation')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailsPanel Output section (read)', () => {
|
||||
function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) {
|
||||
localStorage.clear()
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready' }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
return render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
t={t}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(sessions)}
|
||||
useWorkspaces={bindSnapshotSelector(workspaces)}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useProjection={(() => undefined)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null, ...over,
|
||||
}
|
||||
}
|
||||
|
||||
const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'read' }
|
||||
|
||||
it('renders the read card at full height, keeping the JSON Input section', () => {
|
||||
const long = Array.from({ length: 20 }, (_, i) => ({ number: i + 1, text: `row-${i}` }))
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ resultView: resultRead({ lines: long, totalLines: 20 }) })],
|
||||
}), target)
|
||||
expect(view.getByText(/"file_path"/)).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-read]')).not.toBeNull()
|
||||
// The panel takes the primitive's own default cap (16), not the row's.
|
||||
expect(view.getByText(`… 其余 ${20 - 16} 行`)).toBeTruthy()
|
||||
expect(contentTexts(view.container)).toContain('row-0')
|
||||
})
|
||||
|
||||
it('a non-read result keeps the flattened pre form', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null,
|
||||
content: [{ type: 'text', text: 'plain result' }],
|
||||
})],
|
||||
}), target)
|
||||
expect(view.container.querySelector('[data-read]')).toBeNull()
|
||||
expect(view.getByText('输出').closest('section')?.querySelector('pre')?.textContent).toBe('plain result')
|
||||
})
|
||||
|
||||
it('a running read keeps the 运行中… placeholder (no result view)', () => {
|
||||
const view = mount(snapshot({ runningCalls: [running()] }), target)
|
||||
expect(view.getByText('运行中…')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-read]')).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user