mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(chat): queued follow-up panel no longer overlaps chat content (#4334)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user