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:
@@ -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} />,
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
Reference in New Issue
Block a user