Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
This commit is contained in:
@@ -86,6 +86,8 @@ describe('apply wiring', () => {
|
||||
// service being present implies the chat entry declared the hole first.
|
||||
const entries = b.slots.entries('conversation.chat.toolview')
|
||||
expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write'])
|
||||
// Stats stick with the composer (not inside ChatView).
|
||||
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
// StatsLine (rendered inside the chat view body): totals derivation + the RFC
|
||||
// StatsLine (composer.dock entry): totals derivation + the RFC
|
||||
// hard acceptance — zero renders during streaming. Bash sample row: the
|
||||
// canonical sub-agent differential decided INSIDE the component off the
|
||||
// standard useSessions kit (no registry predicates — tool ring dissolved).
|
||||
|
||||
@@ -378,6 +378,42 @@ describe('ChatView', () => {
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
})
|
||||
|
||||
it('entering the at-bottom threshold does not snap the remaining scroll distance', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
|
||||
// Inside FOLLOW_THRESHOLD (24) but not flush with the floor — the chrome
|
||||
// re-render from setAtBottom must not force scrollTop to scrollHeight.
|
||||
scroller.scrollTop = 690 // distance-to-bottom = 10
|
||||
fireEvent.scroll(scroller)
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
expect(scroller.scrollTop).toBe(690)
|
||||
})
|
||||
|
||||
it('under data-conversation-scroll, bottom-follow targets the host scrollport', () => {
|
||||
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 })
|
||||
// Open jump uses the host, not the local .scroll node.
|
||||
expect(host.scrollTop).toBe(2000)
|
||||
host.scrollTop = 100
|
||||
fireEvent.scroll(host)
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
fireEvent.click(view.getByLabelText('回到底部'))
|
||||
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} />)
|
||||
|
||||
@@ -244,6 +244,56 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
expect((textarea).value).toBe('typed')
|
||||
})
|
||||
|
||||
it('wheel over a non-overflowing textarea forwards to the conversation host', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
|
||||
const { view, textarea } = bench()
|
||||
host.appendChild(view.container)
|
||||
document.body.appendChild(host)
|
||||
try {
|
||||
const wheeled = fireEvent.wheel(textarea, { deltaY: 30 })
|
||||
expect(wheeled).toBe(false) // preventDefault
|
||||
expect(host.scrollTop).toBe(70)
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('wheel chains: long drafts scroll inside the textarea until each edge, then the host', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
|
||||
const { view, textarea } = bench()
|
||||
host.appendChild(view.container)
|
||||
document.body.appendChild(host)
|
||||
Object.defineProperty(textarea, 'clientHeight', { value: 100, configurable: true })
|
||||
Object.defineProperty(textarea, 'scrollHeight', { value: 400, configurable: true })
|
||||
let scrollTop = 150
|
||||
Object.defineProperty(textarea, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => { scrollTop = value },
|
||||
})
|
||||
try {
|
||||
// Mid-draft: both directions stay local — host must not move.
|
||||
expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(true)
|
||||
expect(fireEvent.wheel(textarea, { deltaY: -30 })).toBe(true)
|
||||
expect(host.scrollTop).toBe(40)
|
||||
// At the bottom edge, further down-scroll forwards to the host.
|
||||
scrollTop = 300
|
||||
expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(false)
|
||||
expect(host.scrollTop).toBe(70)
|
||||
// At the top edge, further up-scroll forwards to the host.
|
||||
scrollTop = 0
|
||||
host.scrollTop = 70
|
||||
expect(fireEvent.wheel(textarea, { deltaY: -20 })).toBe(false)
|
||||
expect(host.scrollTop).toBe(50)
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
|
||||
const { textarea } = bench({ disabled: true })
|
||||
expect(textarea.placeholder).toBe('Session unavailable')
|
||||
|
||||
@@ -61,6 +61,8 @@ function mount(
|
||||
snapshot: ConversationSnapshot,
|
||||
workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }],
|
||||
retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}),
|
||||
/** When true, mimic overlay:true chain siblings (hidden fallback + takeover). */
|
||||
overlayTakeover = false,
|
||||
) {
|
||||
const root = sid('root')
|
||||
const sessions = createSnapshotStore<SessionListState>({
|
||||
@@ -112,6 +114,7 @@ function mount(
|
||||
releaseSessionImages={vi.fn()}
|
||||
bindDraftMirror={write => wiring.bindMirror(write)}
|
||||
open={open}
|
||||
{...owner}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -145,7 +148,18 @@ function mount(
|
||||
}
|
||||
return <div data-testid={`view-${opts?.only ?? key}`} />
|
||||
}) as ConversationRootProps['renderSlot']
|
||||
const renderSlotChain = ((_key, _owner, opts) => opts?.fallback ?? null) as ConversationRootProps['renderSlotChain']
|
||||
const renderSlotChain = ((_key, _owner, opts) => (
|
||||
overlayTakeover
|
||||
? (
|
||||
<>
|
||||
<div data-chain-overlay-fallback="conversation.composer" style={{ display: 'none' }}>
|
||||
{opts?.fallback ?? null}
|
||||
</div>
|
||||
<div data-testid="composer-takeover">TAKEOVER</div>
|
||||
</>
|
||||
)
|
||||
: (opts?.fallback ?? null)
|
||||
)) as ConversationRootProps['renderSlotChain']
|
||||
const props: ConversationRootProps = {
|
||||
sessionId: SID,
|
||||
SessionProvider: ({ children }) => children(SID),
|
||||
@@ -180,6 +194,30 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(b.open).toHaveBeenCalledWith(sid('root'))
|
||||
})
|
||||
|
||||
it('active phase: fixed header outside the scrollport; sticky composer seat inside it', () => {
|
||||
const b = mount(conversationSnapshot())
|
||||
const host = b.view.container.querySelector('[data-conversation-scroll]')
|
||||
const seat = b.view.container.querySelector('[data-composer-seat]')
|
||||
const header = b.view.container.querySelector('header')
|
||||
const textarea = b.view.container.querySelector('textarea')
|
||||
expect(host).not.toBeNull()
|
||||
expect(seat).not.toBeNull()
|
||||
expect(header).not.toBeNull()
|
||||
// Header is column chrome above the scrollport; the seat sticks inside it.
|
||||
expect(host?.contains(header)).toBe(false)
|
||||
expect(host?.contains(seat)).toBe(true)
|
||||
expect(seat?.contains(textarea)).toBe(true)
|
||||
})
|
||||
|
||||
it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => {
|
||||
const b = mount(conversationSnapshot(), undefined, undefined, true)
|
||||
const seat = b.view.container.querySelector('[data-composer-seat]')
|
||||
const takeover = b.view.getByTestId('composer-takeover')
|
||||
const fallback = b.view.container.querySelector('[data-chain-overlay-fallback="conversation.composer"]')
|
||||
expect(seat?.contains(takeover)).toBe(true)
|
||||
expect(seat?.contains(fallback)).toBe(true)
|
||||
})
|
||||
|
||||
it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => {
|
||||
const b = mount(
|
||||
conversationSnapshot({ composerPhase: 'blank', blank: true }),
|
||||
@@ -188,13 +226,19 @@ describe('ConversationRoot resident composer', () => {
|
||||
{ ...workspace('second'), title: 'Selected Folder' },
|
||||
],
|
||||
)
|
||||
// Hero chrome present, view ring absent.
|
||||
// Hero chrome present, view ring absent; scroll host already wraps the
|
||||
// resident composer so the blank → active flip does not remount it.
|
||||
const host = b.view.container.querySelector('[data-conversation-scroll]')
|
||||
const header = b.view.container.querySelector('header')
|
||||
expect(host).not.toBeNull()
|
||||
expect(header?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(b.view.getByText("Let's start building")).toBeTruthy()
|
||||
expect(b.view.queryByTestId('view-chat')).toBeNull()
|
||||
// The same machine-backed textarea is live in the hero, and the
|
||||
// persistence mirror stays bound (ConversationSession mounts chrome-less
|
||||
// persistence mirror stays bound (ConversationSession mounts chrome-hidden
|
||||
// for blank sessions): hero typing reaches the chat store.
|
||||
const box = b.view.getByRole('textbox')
|
||||
expect(host?.contains(box)).toBe(true)
|
||||
fireEvent.change(box, { target: { value: 'draft in hero' } })
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('draft in hero')
|
||||
// Picker: open through the chip; a pick switches to the other
|
||||
@@ -207,16 +251,20 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(b.view.getByText('Selected Folder')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('textarea DOM identity survives the hero → active flip', () => {
|
||||
it('same textarea DOM node survives the hero → active flip into the sticky scrollport', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
|
||||
const before = b.view.getByRole('textbox')
|
||||
fireEvent.change(before, { target: { value: 'kept across flip' } })
|
||||
// First message landed: content exists, phase leaves blank.
|
||||
// First message landed: content exists, phase leaves blank. Composer
|
||||
// already sat in the Session scrollport during hero, so the textarea
|
||||
// node and InputHub draft both survive.
|
||||
b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false }))
|
||||
b.rerender()
|
||||
const after = b.view.getByRole('textbox')
|
||||
const after = b.view.getByRole('textbox') as HTMLTextAreaElement
|
||||
expect(after).toBe(before)
|
||||
expect((after as HTMLTextAreaElement).value).toBe('kept across flip')
|
||||
expect(after.value).toBe('kept across flip')
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
|
||||
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
|
||||
expect(b.view.queryByText("Let's start building")).toBeNull()
|
||||
expect(b.view.getByTestId('view-chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user