This commit is contained in:
07akioni
2026-07-29 21:03:40 +08:00
parent 2a12057345
commit 08d097077e
12 changed files with 117 additions and 36 deletions

View File

@@ -227,7 +227,7 @@ describe('running and lock semantics (queue cut 1)', () => {
expect((textarea).value).toBe('typed')
})
it('wheel over the textarea scrolls the conversation host, not a nested textarea port', () => {
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 })
@@ -243,6 +243,40 @@ describe('running and lock semantics (queue cut 1)', () => {
}
})
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')

View File

@@ -59,6 +59,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>({
@@ -132,7 +134,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),
@@ -167,16 +180,28 @@ describe('ConversationRoot resident composer', () => {
expect(b.open).toHaveBeenCalledWith(sid('root'))
})
it('active phase: fixed header outside the scrollport; sticky composer inside it', () => {
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; composer sticks inside it.
// Header is column chrome above the scrollport; the seat sticks inside it.
expect(host?.contains(header)).toBe(false)
expect(host?.contains(textarea)).toBe(true)
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', () => {