Merge remote-tracking branch 'origin/master' into mergebot/pr1008
# Conflicts: # examples/web-cordis/cordis.yml # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/chat/ToolRow.module.css # packages/client/ui-conversation/src/client/chat/ToolRow.tsx # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx # packages/client/ui-conversation/tests/chat-view.spec.tsx
This commit is contained in:
@@ -122,6 +122,13 @@ describe('conversation slot inject surface', () => {
|
||||
const chatView = b.chatViewSurface(ROOT)
|
||||
chatView.injected.loadOlder()
|
||||
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
|
||||
chatView.injected.forkAt(17)
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
|
||||
})
|
||||
expect(b.runtime.sessions.calls).toContainEqual({
|
||||
method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true }],
|
||||
})
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -107,32 +107,48 @@ describe('MessageItem arms', () => {
|
||||
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
})
|
||||
|
||||
it('context nodes render a title-only tool row that expands the injected text without labels', () => {
|
||||
const view = render(
|
||||
it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => {
|
||||
const ctxView = render(
|
||||
<MessageItem node={{
|
||||
kind: 'context', seq: 3, source: null,
|
||||
content: [{ type: 'text', text: 'memory line one\nsecond line' }] as never,
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'x\n"y":,[{}]' }],
|
||||
source: { kind: 'plugin', plugin: 'fixture', empty: {}, list: [] },
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
const row = view.getByRole('button', { name: /上下文注入/ })
|
||||
// Title-only collapsed row: the injected text stays behind the expand.
|
||||
expect(view.queryByText(/memory line one/)).toBeNull()
|
||||
fireEvent.click(row)
|
||||
expect(view.getByText(/second line/)).toBeTruthy()
|
||||
// Label-less card: the injection is ambient context, not a call's IN payload.
|
||||
expect(view.queryByText('IN')).toBeNull()
|
||||
})
|
||||
const disclosure = ctxView.getByRole('button', { name: '上下文注入' })
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull()
|
||||
expect(ctxView.container.querySelector('svg')).not.toBeNull()
|
||||
|
||||
it('a context node without pure text expands to the full JSON payload', () => {
|
||||
const view = render(
|
||||
<MessageItem node={{ kind: 'context', seq: 3, content: [], source: null } as never} />,
|
||||
fireEvent.click(disclosure)
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(ctxView.container.querySelector('[data-context-injection-body]')?.textContent).toBe(
|
||||
'{ "content": [ { "type": "text", "text": "x\\n\\"y\\":,[{}]" } ], '
|
||||
+ '"source": { "kind": "plugin", "plugin": "fixture", "empty": {}, "list": [] } }',
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /上下文注入/ }))
|
||||
expect(view.getByText(/"source": null/)).toBeTruthy()
|
||||
|
||||
fireEvent.keyDown(disclosure, { key: ' ' })
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('unknown nodes render their JSON rows', () => {
|
||||
it('context preserves the bounded JSON truncation contract', () => {
|
||||
const view = render(
|
||||
<MessageItem node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'x'.repeat(21_000) }],
|
||||
source: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '上下文注入' }))
|
||||
expect(view.container.querySelector('[data-context-injection-body]')?.textContent)
|
||||
.toMatch(/… 已截断,共 \d+ 字符$/)
|
||||
})
|
||||
|
||||
it('unknown nodes retain the generic JSON row', () => {
|
||||
const unknownView = render(
|
||||
<MessageItem node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
|
||||
)
|
||||
|
||||
@@ -101,6 +101,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
save: (top: number | null) => { savedScrollTop = top },
|
||||
read: () => savedScrollTop,
|
||||
}
|
||||
const forkAt = vi.fn()
|
||||
// Selection rides the REAL chat store (same construction path as
|
||||
// production; the view reads it through the PropsStore useStore share).
|
||||
// renderSlot stub renders the render-site fallback (an empty keyed ledger:
|
||||
@@ -129,9 +130,10 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
loadOlder,
|
||||
inspectCall,
|
||||
chatScroll,
|
||||
forkAt,
|
||||
}
|
||||
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, setSelection }
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
|
||||
}
|
||||
|
||||
describe('chat-flow derivation', () => {
|
||||
@@ -213,6 +215,16 @@ describe('ChatView', () => {
|
||||
expect(h.inspectCall).toHaveBeenCalledWith('a')
|
||||
})
|
||||
|
||||
it('forks from both user and finalized assistant message actions at their event seq', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(buttons).toHaveLength(2)
|
||||
fireEvent.click(buttons[0]!)
|
||||
fireEvent.click(buttons[1]!)
|
||||
expect(h.forkAt.mock.calls).toEqual([[1], [2]])
|
||||
})
|
||||
|
||||
it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
|
||||
const markdown = '# Rendered\n\n- **one**\n- `two`'
|
||||
const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* QueueDock rendering and operations: authoritative rows, inline editing,
|
||||
* removal, failure notices, and live retirement.
|
||||
* collapse state, removal, failure notices, and live retirement.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
@@ -78,16 +78,109 @@ describe('QueueDock', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders one row directly and defaults multiple rows to a collapsible count header', () => {
|
||||
const single = snapshotWith([row('i-1', 'one')])
|
||||
const source = liveSession(single)
|
||||
const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} />)
|
||||
expect(view.queryByRole('button', { name: '1 条排队消息' })).toBeNull()
|
||||
expect(view.getByText('one')).toBeTruthy()
|
||||
|
||||
act(() => { source.push(snapshotWith([row('i-1', 'one'), row('i-2', 'two')])) })
|
||||
const header = view.getByRole('button', { name: '2 条排队消息' })
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(document.getElementById(header.getAttribute('aria-controls')!)).toBeTruthy()
|
||||
expect(view.queryByText('one')).toBeNull()
|
||||
expect(view.queryByText('two')).toBeNull()
|
||||
|
||||
fireEvent.click(header)
|
||||
expect(header.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.getByText('one')).toBeTruthy()
|
||||
expect(view.getByText('two')).toBeTruthy()
|
||||
|
||||
fireEvent.click(header)
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(view.queryByText('one')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an active single-row editor visible when another item arrives', () => {
|
||||
const single = snapshotWith([row('i-edit', 'before')])
|
||||
const source = liveSession(single)
|
||||
const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} />)
|
||||
|
||||
fireEvent.click(view.getByLabelText('编辑排队消息'))
|
||||
fireEvent.change(view.getByLabelText('编辑排队消息'), { target: { value: 'draft' } })
|
||||
act(() => {
|
||||
source.push(snapshotWith([row('i-edit', 'before'), row('i-2', 'second')]))
|
||||
})
|
||||
|
||||
const header = view.getByRole('button', { name: '2 条排队消息' })
|
||||
expect(header).toHaveProperty('disabled', true)
|
||||
expect(header.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.getByRole('textbox', { name: '编辑排队消息' })).toHaveProperty('value', 'draft')
|
||||
expect(view.getByText('second')).toBeTruthy()
|
||||
|
||||
fireEvent.click(view.getByLabelText('取消编辑'))
|
||||
expect(header).toHaveProperty('disabled', false)
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(view.queryByText('second')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an in-flight row action visible when another item arrives', async () => {
|
||||
const single = snapshotWith([row('i-remove', 'remove me')])
|
||||
const source = liveSession(single)
|
||||
let finishUpdate: (() => void) | undefined
|
||||
const updateQueue = vi.fn(() => new Promise<void>((resolve) => { finishUpdate = resolve }))
|
||||
const view = render(
|
||||
<QueueDock {...kitFor(single, { updateQueue })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
fireEvent.click(view.getByLabelText('删除排队消息'))
|
||||
act(() => {
|
||||
source.push(snapshotWith([row('i-remove', 'remove me'), row('i-2', 'second')]))
|
||||
})
|
||||
|
||||
const header = view.getByRole('button', { name: '2 条排队消息' })
|
||||
expect(header).toHaveProperty('disabled', true)
|
||||
expect(header.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.getByText('remove me')).toBeTruthy()
|
||||
expect(view.getByText('second')).toBeTruthy()
|
||||
|
||||
act(() => { finishUpdate?.() })
|
||||
await waitFor(() => {
|
||||
expect(header).toHaveProperty('disabled', false)
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults a new multi-row queue to collapsed after the prior queue empties', () => {
|
||||
const first = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
|
||||
const source = liveSession(first)
|
||||
const view = render(<QueueDock {...kitFor(first)} useSession={source.useSession} />)
|
||||
fireEvent.click(view.getByRole('button', { name: '2 条排队消息' }))
|
||||
expect(view.getByText('one')).toBeTruthy()
|
||||
|
||||
act(() => { source.push(snapshotWith([])) })
|
||||
expect(view.container.innerHTML).toBe('')
|
||||
act(() => {
|
||||
source.push(snapshotWith([row('i-3', 'three'), row('i-4', 'four')]))
|
||||
})
|
||||
|
||||
const header = view.getByRole('button', { name: '2 条排队消息' })
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(view.queryByText('three')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders active actions and disables editing for mixed-content rows', () => {
|
||||
const snap = snapshotWith([
|
||||
row('i-1', '第一条排队消息'),
|
||||
row('i-2', null, 'image [image]'),
|
||||
])
|
||||
const source = liveSession(snap)
|
||||
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
|
||||
const { container, getByRole } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
|
||||
fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
|
||||
expect([...container.querySelectorAll('li')].map(item => item.textContent))
|
||||
.toEqual(['第一条排队消息', 'image [image]'])
|
||||
expect(container.querySelectorAll('button')).toHaveLength(4)
|
||||
expect(container.querySelectorAll('button')).toHaveLength(5)
|
||||
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('[aria-label="删除排队消息"]')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('[aria-label="立即发送排队消息"]')).toHaveLength(0)
|
||||
@@ -162,10 +255,11 @@ describe('QueueDock', () => {
|
||||
const snap = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
|
||||
const source = liveSession(snap)
|
||||
const updateQueue = vi.fn(() => Promise.resolve())
|
||||
const { getAllByLabelText } = render(
|
||||
const { getAllByLabelText, getByRole } = render(
|
||||
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
|
||||
fireEvent.click(getAllByLabelText('删除排队消息')[0]!)
|
||||
await waitFor(() => {
|
||||
expect(updateQueue).toHaveBeenCalledWith(iid('i-1'), { kind: 'remove' })
|
||||
@@ -197,9 +291,14 @@ describe('QueueDock', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('ships the session-scoped registrant plugin shape', () => {
|
||||
it('registers as the terminal composer-context entry', () => {
|
||||
expect(queueDockEntry.name).toBe('conversation-queue-dock')
|
||||
expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions'])
|
||||
expect(typeof queueDockEntry.apply).toBe('function')
|
||||
const register = vi.fn()
|
||||
queueDockEntry.apply({ slots: { register } } as never)
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'conversation.input.dock', id: 'queue', order: 20 }),
|
||||
QueueDock,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -92,12 +92,12 @@ describe('TodoDock', () => {
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
})
|
||||
|
||||
it('ships the registrant plugin shape (list entry above the queue rows)', () => {
|
||||
it('registers between the goal and queue entries', () => {
|
||||
expect(todoDockEntry.name).toBe('conversation-todo-dock')
|
||||
expect(todoDockEntry.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
todoDockEntry.apply({ slots: { register } } as never)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user