fix(chat): queued follow-up panel no longer overlaps chat content (#4334)

This commit is contained in:
Cyrus Gray
2026-06-30 16:44:04 +05:30
committed by GitHub
parent 45c2a6a2ea
commit e470d05eae
2 changed files with 204 additions and 2 deletions
+39 -2
View File
@@ -281,6 +281,13 @@ const Conversations = ({
const [isTranscribing, setIsTranscribing] = useState(false);
const [voiceStatus, setVoiceStatus] = useState<string | null>(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<HTMLTextAreaElement>(null);
const composerFooterRef = useRef<HTMLDivElement>(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 = ({
</div>
) : hasVisibleMessages || hasTaskBoard ? (
<div
data-testid="chat-message-list"
className={`mx-auto w-full max-w-[48.75rem] space-y-3 px-5 pt-4 ${
isSidebar ? 'pb-4' : 'pb-32'
}`}>
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 = ({
)}
<div
ref={composerFooterRef}
data-walkthrough="home-cta"
// Page variant: float at the bottom (absolute) over the fade; centered +
// width-capped to match the messages. `z-20` keeps it above messages
@@ -21,6 +21,8 @@ import agentProfileReducer from '../../store/agentProfileSlice';
import chatRuntimeReducer, {
appendProcessingProse,
beginInferenceTurn,
clearFollowupsForThread,
enqueueFollowup,
setInferenceStatusForThread,
setPendingPlanReviewForThread,
setTaskBoardForThread,
@@ -2395,3 +2397,166 @@ describe('Conversations — queued follow-ups while a turn streams', () => {
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<typeof buildStore> | 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<typeof buildStore>, 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;
}
});
});