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