feat(web): unify tool-row expand interaction with IN/OUT card and trajectory Inspect
Every expandable tool row shares one interaction (whole-row toggle, icon-to-chevron hover preview) and one expanded body: an IN/OUT gutter-labeled card with per-section 150px scroll caps and sticky labels. toolRowModel derives result output and the error first line, terminalFailed surfaces a failing exit as the collapsed row's red dot, a hover Inspect pill jumps to the call's trajectory record through a one-shot store handoff, and the chat view keeps its scroll offset across view switches.
This commit is contained in:
@@ -94,6 +94,13 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
const openDetails = vi.fn<(t: SelectionTarget) => void>()
|
||||
const openFile = vi.fn<(path: string) => void>()
|
||||
const loadOlder = vi.fn()
|
||||
const inspectCall = vi.fn<(callId: string) => void>()
|
||||
// In-memory scroll memory matching the apply.ts per-session map contract.
|
||||
let savedScrollTop: number | null = null
|
||||
const chatScroll = {
|
||||
save: (top: number | null) => { savedScrollTop = top },
|
||||
read: () => savedScrollTop,
|
||||
}
|
||||
// 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:
|
||||
@@ -120,9 +127,11 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
openDetails,
|
||||
openFile,
|
||||
loadOlder,
|
||||
inspectCall,
|
||||
chatScroll,
|
||||
}
|
||||
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, setSelection }
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, setSelection }
|
||||
}
|
||||
|
||||
describe('chat-flow derivation', () => {
|
||||
@@ -194,6 +203,16 @@ describe('ChatView', () => {
|
||||
expect(view.getByText('run a')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('the expanded row Inspect pill hands the call id to inspectCall', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [toolResult(3, 'a')],
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
|
||||
fireEvent.click(view.getByText('Inspect'))
|
||||
expect(h.inspectCall).toHaveBeenCalledWith('a')
|
||||
})
|
||||
|
||||
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)] })
|
||||
@@ -280,11 +299,11 @@ describe('ChatView', () => {
|
||||
expect(rowRenders).toBe(afterMount)
|
||||
})
|
||||
|
||||
it('tool row expands to the args body via the leading slot toggle', () => {
|
||||
it('tool row expands to the args body via the whole-row toggle', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.queryByText(/"command": "cmd-a"/)).toBeNull()
|
||||
fireEvent.click(view.container.querySelector('button[aria-expanded]')!)
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -407,6 +426,55 @@ describe('ChatView', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('a remount restores the saved scroll position instead of re-jumping to the bottom', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
document.body.appendChild(host)
|
||||
try {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
// Fresh open (nothing saved): the bottom jump stands.
|
||||
const view = render(<h.ChatView {...h.props} />, { container: host })
|
||||
expect(host.scrollTop).toBe(2000)
|
||||
// Reader scrolls up; the position is recorded continuously.
|
||||
host.scrollTop = 100
|
||||
fireEvent.scroll(host)
|
||||
// View-tab switch away and back: the view unmounts, then remounts.
|
||||
view.rerender(<div />)
|
||||
host.scrollTop = 0
|
||||
view.rerender(<h.ChatView {...h.props} />)
|
||||
expect(host.scrollTop).toBe(100)
|
||||
// The restored position is above the floor: follow stays disarmed.
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('a remount while pinned to the bottom keeps the bottom jump', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
document.body.appendChild(host)
|
||||
try {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />, { container: host })
|
||||
// At the bottom: the scroll event records the pinned state (null).
|
||||
fireEvent.scroll(host)
|
||||
expect(h.chatScroll.read()).toBeNull()
|
||||
view.rerender(<div />)
|
||||
host.scrollTop = 0
|
||||
view.rerender(<h.ChatView {...h.props} />)
|
||||
expect(host.scrollTop).toBe(2000)
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('paging button loads older and shows its busy label', () => {
|
||||
const h = makeHarness({ nodes: [user(5, 'later')], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
|
||||
Reference in New Issue
Block a user