feat(web): name context sources and mark steering in the transcript

Every logged non-user user/message collapsed into one identical
「上下文注入」 row, and mid-turn steering rendered in exactly the bubble a
turn-opening prompt uses, so the transcript could not say what had been
added or which message interrupted a running turn.

TranscriptAdapter and the history fold now attach a provenance view to
each context node, computed by contextProvenance() from the durable
source alone: a role (inject, or recall for a cross-session snapshot)
and a producer name read out of the log — instruction paths for
workspace-instructions, session titles for session-reference, the plugin
id for a plugin source, and the bare kind for any other. No client-side
table of producer names, so a renamed or newly mounted producer stays
identifiable without a client release and a foreign log projects like a
live one. ContextInjectionRow titles itself from the role and shows the
name beside it; MessageItem captions durable and pending steering
bubbles.

The caption reverses one clause of the no-interjection-chrome decision,
which removed it because the composer could not steer; composer steering
shipped afterwards without amending that note, so this change supplies
the product decision its reintroduction clause required and corrects the
stale facts left in it.

Fixes #1291
This commit is contained in:
creatixchu
2026-08-04 17:44:18 +08:00
parent 11bad56fc3
commit de96087923
50 changed files with 380 additions and 74 deletions

View File

@@ -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/runtime/README.md
README.md: 89e58f967f852bb0786a5b7d73fa8e924fa282e0
README.zh.md: 960e2fceede1b500af9ee2063ec9283e2b7b271a
README.md: 80fb7e02ff357694bef5237a86983a22bf222572
README.zh.md: b242720fc1ed693a1f681c186adf5ef4f1cef42a

View File

@@ -26,7 +26,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
## The human transcript
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally.
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally.
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.

View File

@@ -26,7 +26,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 面向人的 transcript文本记录
`ConversationSnapshot.nodes` 是面向人的 transcript不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`落在它自己的日志位置上外加每次落地的压缩compaction检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败而对该包package做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。
`ConversationSnapshot.nodes` 是面向人的 transcript不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`落在它自己的日志位置上外加每次落地的压缩compaction检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败而对该包package做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。

View File

@@ -53,6 +53,7 @@ export type {
export type {
ConversationContext, ConversationContextOriginKind,
} from './sessions/conversation-context.ts'
export type { ContextProvenanceView, ContextRole } from './sessions/context-provenance.ts'
export type {
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
} from './sessions/request-inspection.ts'

View File

@@ -11,6 +11,7 @@ import type {
PartialAssistant, RunningToolCall,
} from '../sessions/conversation.ts'
import { toAssistantBlocks } from '../sessions/conversation.ts'
import { contextProvenance } from '../sessions/context-provenance.ts'
import type {
ConversationContext, ConversationContextOriginKind,
} from '../sessions/conversation-context.ts'
@@ -126,6 +127,7 @@ function materializeNode(
return {
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
provenance: contextProvenance(event.data.source),
}
}
return {

View File

@@ -0,0 +1,91 @@
// Context provenance projection: the role and the human-facing producer name
// of one logged non-user `user/message`, read from its durable `source` alone.
// The client keeps no table of known plugin ids — a renamed or newly mounted
// producer must never need a client release to stay identifiable, and a resumed
// or foreign log must project the same way as a live one.
/**
* Which model-facing role a logged non-user message plays.
*
* `recall` marks material lifted out of another session's log; `inject` marks
* every other producer-supplied context. Mid-turn steering is the third role
* the transcript distinguishes, but it has its own event and node kind
* (`steering/message` / `SteeringMessageNode`) and never reaches here.
*/
export type ContextRole = 'inject' | 'recall'
/** Role and producer name presented for one logged non-user message. */
export interface ContextProvenanceView {
/** The role this context plays in the model-facing conversation. */
role: ContextRole
/**
* Producer name for the row header, taken from the durable source: the
* instruction paths, the referenced session titles, the plugin id, or the
* bare source kind for a producer this UI version does not know. Null only
* when the source carries no readable kind at all.
*/
label: string | null
}
/** One durable source narrowed to the readable-record shape; null for anything else. */
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
/** A record field read as a non-empty string, or null. */
function readString(record: Record<string, unknown>, key: string): string | null {
const value = record[key]
return typeof value === 'string' && value.length > 0 ? value : null
}
/** Distinct non-empty `field` values of an array-valued source member, in first-seen order. */
function collect(source: Record<string, unknown>, member: string, field: string): string[] {
const list = source[member]
if (!Array.isArray(list)) return []
const seen: string[] = []
for (const entry of list) {
const record = asRecord(entry)
const value = record === null ? null : readString(record, field)
if (value !== null && !seen.includes(value)) seen.push(value)
}
return seen
}
/** A collected name list rendered as one label; null when the list is empty. */
function joined(names: string[]): string | null {
return names.length > 0 ? names.join(', ') : null
}
/**
* Project one durable message source onto its transcript role and producer name.
*
* The source arrives over the wire as opaque JSON (`MessageSource` is
* merge-extensible, so no client-side union can be exhaustive), and a durable
* log may predate or postdate this UI; every unreadable shape therefore
* degrades to `inject` with whatever name the record still carries.
* @param source - the logged `user/message` source, exactly as recorded.
* @returns the role and producer name to present for this context.
*/
export function contextProvenance(source: unknown): ContextProvenanceView {
const record = asRecord(source)
const kind = record === null ? null : readString(record, 'kind')
if (record === null || kind === null) return { role: 'inject', label: null }
switch (kind) {
// Cross-session snapshots are the one durable source that carries another
// session's material; its references name the sessions they were read from.
case 'session-reference':
return { role: 'recall', label: joined(collect(record, 'references', 'label')) ?? kind }
// Workspace instructions name the files they were reconciled from, which
// identifies the producer far better than the plugin id would.
case 'workspace-instructions':
return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind }
case 'plugin':
return { role: 'inject', label: readString(record, 'plugin') ?? kind }
// Documented default arm of the merge-extensible source map: an unknown
// producer still identifies itself by its own durable kind.
default:
return { role: 'inject', label: kind }
}
}

View File

@@ -12,6 +12,7 @@ import type {
InboxItemId, RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
import type { ContextProvenanceView } from './context-provenance.ts'
export type { TodoItem }
/** Request configuration recorded for one provider call. */
@@ -123,6 +124,8 @@ export interface ContextMessageNode {
time: number
content: readonly ContentBlock[]
source: unknown
/** Role and producer name projected from `source` ({@link contextProvenance}). */
provenance: ContextProvenanceView
}
/** Durable notice that a closed failed step is waiting for a model-request retry. */

View File

@@ -22,6 +22,7 @@ import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpo
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
import { contextProvenance } from './context-provenance.ts'
/**
* The compaction seam's checkpoint plugin, pinned to the seam's own declaration
@@ -60,6 +61,7 @@ function materializeNode(
return {
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
provenance: contextProvenance(event.data.source),
}
}
return {

View File

@@ -208,7 +208,9 @@ describe('TranscriptAdapter', () => {
source: { kind: 'plugin', plugin: 'compact' },
}) }),
])
expect(adapter.nodes()).toMatchObject([{ kind: 'context', seq: 0 }])
expect(adapter.nodes()).toMatchObject([{
kind: 'context', seq: 0, provenance: { role: 'inject', label: 'compact' },
}])
})
it('ignores a foreign plugin s replacement user/message', () => {

View File

@@ -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: 7d3a4b5fe07cc8858c2f2059e6f65b6e27602b4d
README.zh.md: d93af91381157bb4e8e4b6a14ad00edecd505246
README.md: c7ac25a47eceb2f06ae90bf540a37b77c67a0d8a
README.zh.md: 1b1d250b8623aa164119e85caa2631805b2879a4

View File

@@ -14,7 +14,7 @@ Approvals take over the composer through the chain this package declares: `Appro
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([disclosure decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). A durable or pending steering bubble carries an `插话` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble.
A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)).

View File

@@ -12,7 +12,7 @@
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px超出后滚动并以内联 JSON 展示 `content``source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill 目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px超出后滚动并以内联 JSON 展示 `content``source`,且不会合成工具状态、摘要或键控 toolview 分发([展开项决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。持久与待处理的 steering中途引导气泡上方带有 `插话` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识
Think 行默认保持折叠并在不展开思维链的情况下暴露实时推理reasoning吞吐当推理块是流式输出尾部时摘要从结算后的首行切换到最新的非空行其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。

View File

@@ -12,6 +12,20 @@
color: var(--dsw-alias-label-secondary);
}
/* Producer name beside the role title: ToolRow's summary geometry, so the two
disclosure rows keep one 24px rhythm; subordinate color, truncated when long. */
.source {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
margin-left: 4px;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.body {
box-sizing: border-box;
width: calc(100% - 22px);

View File

@@ -48,16 +48,22 @@ function inlineJson(payload: unknown): string {
export interface ContextInjectionRowProps {
content: ContextMessageNode['content']
source: ContextMessageNode['source']
/** Role and producer name projected from the durable source. */
provenance: ContextMessageNode['provenance']
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
/**
* Render logged context with the Tool calls disclosure chrome from Figma.
* @param props - Durable content and source provenance.
*
* The header names the role the context plays and, beside it, the producer the
* durable source identifies, so a reader can tell an injected skill catalog
* from a workspace instruction file or a recalled session without expanding.
* @param props - Durable content, its projected provenance, and the locale seat.
* @returns A collapsed context row with a bounded JSON body.
*/
export function ContextInjectionRow({ content, source, t }: ContextInjectionRowProps) {
export function ContextInjectionRow({ content, source, provenance, t }: ContextInjectionRowProps) {
const [open, setOpen] = useState(false)
const body = useMemo(() => {
if (!open) return ''
@@ -72,7 +78,11 @@ export function ContextInjectionRow({ content, source, t }: ContextInjectionRowP
className={css.root}
icon={<IconBrowseOutline16 size={14} />}
chevronClassName={css.chevron}
title={t('message.contextInjection')}
title={t(provenance.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')}
collapsedContent={provenance.label === null ? undefined : (
<span className={css.source} data-context-source>{`· ${provenance.label}`}</span>
)}
keepContentWhenOpen
open={open}
expandable
expandOnRowClick

View File

@@ -8,6 +8,15 @@
gap: 6px;
}
/* Steering caption above the bubble: mid-turn interjections carry the same
bubble as a turn-opening prompt, so the transcript names which one this is. */
.steeringMark {
padding-right: 4px;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 16px;
}
.bubble {
/* 525px cap inside the 736 column; percentage keeps narrow windows sane. */
max-width: min(525px, 82%);

View File

@@ -171,19 +171,22 @@ function projectUserText(text: string): ReactNode {
/** Right-aligned bubble shared by user and steering rows. */
function UserStyleBubble({
content, actions, pending = false, t,
content, actions, pending = false, steering = false, t,
}: {
content: readonly unknown[]
/** Optional IconActions (or similar) below the bubble; receives the joined text. */
actions?: (text: string) => ReactNode
/** Whether this is the Host-authoritative pre-admission steering projection. */
pending?: boolean
/** Marks the bubble as mid-turn steering rather than a turn-opening prompt. */
steering?: boolean
t: ChatViewSlotProps['t']
}): ReactNode {
const { text, rest } = contentText(content)
const truncated = (total: number): string => t('json.truncated', { total })
return (
<div className={css.userRow} data-pending-steering={pending || undefined} data-time-hover-root>
{steering && <span className={css.steeringMark} data-steering-mark>{t('message.steering')}</span>}
<div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
@@ -207,6 +210,7 @@ export function PendingSteeringBubble({ content, t }: {
<UserStyleBubble
content={content}
pending
steering
t={t}
actions={text => (
<MessageIconActions
@@ -231,6 +235,7 @@ export const MessageItem = memo(function MessageItem({
return (
<UserStyleBubble
content={node.content}
steering={node.kind === 'steering'}
t={t}
actions={text => (
<MessageIconActions
@@ -247,7 +252,12 @@ export const MessageItem = memo(function MessageItem({
)
case 'context':
return (
<ContextInjectionRow content={node.content} source={node.source} t={t} />
<ContextInjectionRow
content={node.content}
source={node.source}
provenance={node.provenance}
t={t}
/>
)
case 'compaction':
return <CompactionItem node={node} t={t} />

View File

@@ -52,6 +52,8 @@ export const zh = {
'chat.toBottom': '回到底部',
'message.extraBlock': '附加内容块',
'message.contextInjection': '上下文注入',
'message.contextRecall': '跨会话召回',
'message.steering': '插话',
'message.compaction': '上下文已压缩',
'message.compaction.expand': '点击查看压缩摘要',
'message.compaction.unavailable': '压缩摘要不可用',
@@ -163,6 +165,8 @@ export const en = {
'chat.toBottom': 'Back to bottom',
'message.extraBlock': 'Extra content block',
'message.contextInjection': 'Context injection',
'message.contextRecall': 'Session recall',
'message.steering': 'Steer',
'message.compaction': 'Context compacted',
'message.compaction.expand': 'View compaction summary',
'message.compaction.unavailable': 'Compaction summary unavailable',

View File

@@ -130,7 +130,7 @@ describe('MessageItem arms', () => {
fireEvent.click(screen.getByRole('button', { name: '复制' }))
})
it('consumed steering renders copy and branch actions without a badge', () => {
it('consumed steering is captioned as an interjection and keeps copy and branch actions', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
@@ -145,7 +145,7 @@ describe('MessageItem arms', () => {
onFork={fork}
/>,
)
expect(view.queryByText('插话')).toBeNull()
expect(view.getByText('插话')).toBeTruthy()
expect(view.getByText('steer!')).toBeTruthy()
expect(view.getByText(/附加内容块/)).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: '复制' }))
@@ -161,10 +161,11 @@ describe('MessageItem arms', () => {
seq: 3,
content: [{ type: 'text', text: 'x\n"y":,[{}]' }],
source: { kind: 'plugin', plugin: 'fixture', empty: {}, list: [] },
provenance: { role: 'inject', label: 'fixture' },
} as never}
/>,
)
const disclosure = ctxView.getByRole('button', { name: '上下文注入' })
const disclosure = ctxView.getByRole('button', { name: /^上下文注入\s*· fixture$/ })
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull()
expect(ctxView.container.querySelector('svg')).not.toBeNull()
@@ -187,6 +188,7 @@ describe('MessageItem arms', () => {
seq: 3,
content: [{ type: 'text', text: 'x'.repeat(21_000) }],
source: null,
provenance: { role: 'inject', label: null },
} as never}
/>,
)
@@ -195,6 +197,50 @@ describe('MessageItem arms', () => {
.toMatch(/… 已截断,共 \d+ 字符$/)
})
it('a recalled session titles its row by role and names the sessions it read', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'snapshot' }],
source: { kind: 'session-reference', version: 1, references: [{ label: '重构 loader' }] },
provenance: { role: 'recall', label: '重构 loader' },
} as never}
/>,
)
expect(view.getByRole('button', { name: /^跨会话召回\s*· 重构 loader$/ })).toBeTruthy()
expect(view.queryByText('上下文注入')).toBeNull()
})
it('keeps the producer name visible while the context body is expanded', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'instructions' }],
source: { kind: 'workspace-instructions', changes: [{ path: 'AGENTS.md' }] },
provenance: { role: 'inject', label: 'AGENTS.md' },
} as never}
/>,
)
const disclosure = view.getByRole('button', { name: /^上下文注入\s*· AGENTS\.md$/ })
fireEvent.click(disclosure)
expect(disclosure.getAttribute('aria-expanded')).toBe('true')
expect(view.container.querySelector('[data-context-source]')?.textContent).toBe('· AGENTS.md')
})
it('a context source that names no producer shows the role alone', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: 'x' }], source: null,
provenance: { role: 'inject', label: null },
} as never}
/>,
)
expect(view.getByRole('button', { name: '上下文注入' })).toBeTruthy()
expect(view.container.querySelector('[data-context-source]')).toBeNull()
})
it('unknown nodes retain the generic JSON row', () => {
const unknownView = render(
<MessageItem t={t} node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,

View File

@@ -380,6 +380,9 @@ describe('ChatView', () => {
expect(view.queryByText('later')).toBeNull()
const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]')
expect(pendingBubble).not.toBeNull()
// Pending and durable steering carry the same interjection caption, so the
// hand-off does not change what the row says it is.
expect(within(pendingBubble as HTMLElement).getByText('插话')).toBeTruthy()
fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('interrupt now')
expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
@@ -401,6 +404,7 @@ describe('ChatView', () => {
})
expect(view.getAllByText('interrupt now')).toHaveLength(1)
expect(view.container.querySelector('[data-pending-steering]')).toBeNull()
expect(view.getAllByText('插话')).toHaveLength(1)
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement
const unavailable = within(durableBubble).getByRole('button', { name: '在新对话中分支' })
@@ -447,6 +451,7 @@ describe('ChatView', () => {
const nextRetry = { ...retry(3), turn: 2, retry: 2 }
const context = {
kind: 'context', seq: 4, time: 4_000, content: [], source: null,
provenance: { role: 'inject', label: null },
} as const satisfies ConversationNode
const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true })
const view = render(<h.ChatView {...h.props} />)

View File

@@ -13,6 +13,7 @@ const checkpoint = {
time: 100,
content: [],
source: { kind: 'plugin', plugin: 'compact' },
provenance: { role: 'inject', label: 'compact' },
} as ConversationNode
const abandoned = {