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
@@ -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'
@@ -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 {
@@ -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 }
}
}
@@ -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. */
@@ -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 {