diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 1b8aeaf86..88beb9135 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -281,6 +281,13 @@ const Conversations = ({ const [isTranscribing, setIsTranscribing] = useState(false); const [voiceStatus, setVoiceStatus] = useState(null); const [isPlayingReply, setIsPlayingReply] = useState(false); + // Measured height of the floating composer footer (page variant only). The + // footer is `absolute`ly positioned over the scroll area, so the message list + // needs matching bottom padding to keep its tail visible. Defaults to 128px + // (the old static `pb-32`) so layout is unchanged until the ResizeObserver + // reports a real height — and grows automatically when the queued-followups + // panel, approval cards, or error banners expand the footer (#4268). + const [composerFooterHeight, setComposerFooterHeight] = useState(128); // Thread-list filtering is fixed to the General bucket — the in-sidebar // General/Subconscious/Tasks chips were removed. Subconscious reflections and // task/worker threads have dedicated surfaces (Intelligence, Tasks board). @@ -444,6 +451,7 @@ const Conversations = ({ }, [agentProfiles, selectedAgentProfileId]); const textInputRef = useRef(null); + const composerFooterRef = useRef(null); const isComposingTextRef = useRef(false); // Threads with an in-flight send, guarding against double-submit to the SAME // thread. Per-thread (a Set) so a send to thread B isn't blocked by an @@ -1760,6 +1768,27 @@ const Conversations = ({ const isNewWindow = !isSidebar && !isLoadingMessages && !messagesError && !hasVisibleMessages && !hasTaskBoard; + // Track the floating composer footer's height so the message list can reserve + // matching bottom padding. In the page variant the footer is absolutely + // positioned over the scroll area, so a static padding (the old `pb-32`) gets + // overrun whenever the footer grows — most visibly when the "Queued + // follow-ups" panel appears mid-reply, hiding the tail of the response + // (#4268). The sidebar variant lays the composer out in normal flow and never + // overlaps, so we skip the observer there and keep its `pb-4`. + useEffect(() => { + if (isSidebar) return; + const el = composerFooterRef.current; + if (!el) return; + const measure = () => { + const next = Math.round(el.getBoundingClientRect().height); + if (next > 0) setComposerFooterHeight(next); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [isSidebar, selectedThreadId]); + // Stable title resolver used by both the sidebar thread list and the header. const resolveThreadDisplayTitle = (threadId: string | null): string => { if (!threadId) return t('chat.selectThread'); @@ -2064,9 +2093,16 @@ const Conversations = ({ ) : hasVisibleMessages || hasTaskBoard ? (
+ isSidebar ? 'pb-4' : '' + }`} + // Page variant: reserve room for the absolutely-positioned floating + // composer footer so its tail stays visible. Tracks the footer's + // measured height (+16px gap) instead of a static `pb-32`, so the + // queued-followups panel and other dynamic footer content never + // overlap the last message (#4268). + style={!isSidebar ? { paddingBottom: composerFooterHeight + 16 } : undefined}> {visibleMessages.map(msg => { const isAgentTextMode = msg.sender === 'agent' && agentMessageViewMode === 'text'; // Parsed once per message: for current messages (extraMetadata @@ -2500,6 +2536,7 @@ const Conversations = ({ )}
{ expect(textarea).toHaveValue('keep me on failure'); }); }); + +describe('Conversations — message list reserves room for the floating composer footer (#4268)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetThreads.mockResolvedValue({ threads: [], count: 0 }); + mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 }); + }); + + function followupMessage(id: string, content: string): ThreadMessage { + return { + id, + sender: 'user', + type: 'text', + content, + extraMetadata: {}, + createdAt: '2026-01-01T00:00:00.000Z', + }; + } + + // Page-variant conversation with a visible message and an active (streaming) + // turn, so the message list, the follow-up composer, and the queued-followups + // panel all render together — the exact state where the overlap bug appeared. + async function renderActiveConversationWithMessages() { + const thread = makeThread({ id: 'pad-thread', title: 'Pad Thread' }); + const messages: ThreadMessage[] = [ + { + id: 'm-agent', + sender: 'agent', + type: 'text', + content: 'Streaming agent reply tail that must stay visible.', + extraMetadata: {}, + createdAt: '2026-01-01T00:01:00.000Z', + }, + ]; + mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 }); + mockGetThreadMessages.mockResolvedValue({ messages, count: messages.length }); + let store: ReturnType | undefined; + await act(async () => { + store = await renderConversations({ + thread: { + ...selectedThreadState(thread), + messagesByThreadId: { [thread.id]: messages }, + messages, + activeThreadIds: { [thread.id]: true }, + }, + socket: socketState('connected'), + }); + }); + return { store: store as ReturnType, thread }; + } + + function paddingBottomPx(): number { + return parseInt(screen.getByTestId('chat-message-list').style.paddingBottom || '0', 10); + } + + it('reserves bottom padding via an inline style, not the static pb-32 (0 follow-ups)', async () => { + await renderActiveConversationWithMessages(); + const list = screen.getByTestId('chat-message-list'); + // The old static `pb-32` is gone; spacing is reserved by a dynamic inline + // style so it can grow with the footer. + expect(list.className).not.toMatch(/\bpb-32\b/); + expect(list.style.paddingBottom).not.toBe(''); + expect(paddingBottomPx()).toBeGreaterThan(0); + expect(screen.queryByTestId('queued-followups')).not.toBeInTheDocument(); + }); + + it('keeps content padded when a single follow-up is queued', async () => { + const { store, thread } = await renderActiveConversationWithMessages(); + await act(async () => { + store.dispatch( + enqueueFollowup({ + threadId: thread.id, + message: followupMessage('f1', 'and the pricing?'), + label: 'and the pricing?', + }) + ); + }); + expect(await screen.findByTestId('queued-followups')).toBeInTheDocument(); + expect(paddingBottomPx()).toBeGreaterThan(0); + }); + + it('keeps content padded with multiple queued follow-ups', async () => { + const { store, thread } = await renderActiveConversationWithMessages(); + await act(async () => { + store.dispatch( + enqueueFollowup({ + threadId: thread.id, + message: followupMessage('f1', 'one'), + label: 'one', + }) + ); + store.dispatch( + enqueueFollowup({ + threadId: thread.id, + message: followupMessage('f2', 'two'), + label: 'two', + }) + ); + store.dispatch( + enqueueFollowup({ + threadId: thread.id, + message: followupMessage('f3', 'three'), + label: 'three', + }) + ); + }); + const strip = await screen.findByTestId('queued-followups'); + expect(within(strip).getByText('one')).toBeInTheDocument(); + expect(within(strip).getByText('three')).toBeInTheDocument(); + expect(paddingBottomPx()).toBeGreaterThan(0); + }); + + it('restores normal layout after the queued follow-ups are dismissed', async () => { + const { store, thread } = await renderActiveConversationWithMessages(); + await act(async () => { + store.dispatch( + enqueueFollowup({ + threadId: thread.id, + message: followupMessage('f1', 'dismiss me'), + label: 'dismiss me', + }) + ); + }); + expect(await screen.findByTestId('queued-followups')).toBeInTheDocument(); + await act(async () => { + store.dispatch(clearFollowupsForThread({ threadId: thread.id })); + }); + await waitFor(() => expect(screen.queryByTestId('queued-followups')).not.toBeInTheDocument()); + // Baseline padding stays reserved so the layout is still correct. + expect(paddingBottomPx()).toBeGreaterThan(0); + }); + + it('grows the reserved padding to the footer height reported by the ResizeObserver', async () => { + // jsdom performs no layout, so the polyfilled ResizeObserver never fires. + // Drive it manually: capture the observed element + callback, report a + // concrete footer height, and assert the list reserves that height + the + // 16px gap. + const original = globalThis.ResizeObserver; + const handle: { cb: ResizeObserverCallback | null } = { cb: null }; + class MockResizeObserver { + constructor(cb: ResizeObserverCallback) { + handle.cb = cb; + } + observe() {} + unobserve() {} + disconnect() {} + } + globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; + try { + await renderActiveConversationWithMessages(); + const footer = document.querySelector('[data-walkthrough="home-cta"]') as HTMLElement | null; + expect(footer).not.toBeNull(); + (footer as HTMLElement).getBoundingClientRect = () => ({ height: 240 }) as DOMRect; + await act(async () => { + handle.cb?.([], {} as ResizeObserver); + }); + // 240px measured footer + 16px gap. + expect(paddingBottomPx()).toBe(256); + } finally { + globalThis.ResizeObserver = original; + } + }); +});