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

@@ -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',