Chuyển tới nội dung chính

Placeholder Scroll — User Message Top Viewport

Khi send message mới, user message nằm ở top viewport với khoảng trống bên dưới cho AI response fill dần. Giống behavior của Messenger, iMessage.


Visual

┌─────────────────────────────────┐
│ [history messages ẩn phía trên] │ ← scroll lên để xem
├─────────────────────────────────┤
│ 👤 thị trường hôm nay │ ← user message ở TOP
│ │
│ ⚛ Nebula │ ← brand header
│ ●●● │ ← loading dots
│ │
│ │ ← khoảng trống
│ │ (AI response fill dần)
│ │
├─────────────────────────────────┤
│ [input box] │
└─────────────────────────────────┘

Logic

Tính placeholder height

placeholderHeight = containerHeight - headerHeight - inputHeight - spacing

Tính trước khi render, ngay khi user submit message.

Render

Chia grouped items thành 2 phần: trướctừ last user message.

[history items]                              ← render bình thường
[wrapper div ref=liveRef minHeight=ph] ← wrap từ last user trở đi
[user message] ← ở top viewport sau scroll
[brand + loading / AI response] ← fill bên dưới

scrollIntoView(liveRef) → scroll đến wrapper → user message ở top viewport.

Khi nào active

ScenarioplaceholderHeightBehavior
Send message mới> 0Placeholder active, scroll to top
F5 / load history0Không placeholder, render bình thường
Agent doneReset về 0Placeholder biến mất

React

function ChatStream({ blocks, isStreaming, placeholderHeight, liveRef, ... }) {
const groupedItems = buildGroups(blocks, isStreaming);

// Tìm last user message
const lastUserIndex = useMemo(() => {
for (let i = groupedItems.length - 1; i >= 0; i--) {
if (groupedItems[i].type === 'user') return i;
}
return -1;
}, [groupedItems]);

const hasPlaceholder = placeholderHeight > 0 && lastUserIndex >= 0;

return (
<div>
{hasPlaceholder ? (
<>
{/* History phía trên */}
{groupedItems.slice(0, lastUserIndex).map(renderItem)}

{/* Wrapper: user message + AI response */}
<div ref={liveRef} style={{ minHeight: placeholderHeight }}>
{groupedItems.slice(lastUserIndex).map(renderItem)}
{isStreaming && <LoadingDots />}
</div>
</>
) : (
<>
{groupedItems.map(renderItem)}
{isStreaming && <LoadingDots />}
</>
)}
</div>
);
}

Scroll trigger

useLayoutEffect(() => {
if (placeholderHeight > 0) {
requestAnimationFrame(() => {
liveRef.current?.scrollIntoView({ behavior: 'auto' });
});
}
}, [placeholderHeight]);

Flutter

class ChatStreamWidget extends StatelessWidget {
final double placeholderHeight;
final GlobalKey liveKey = GlobalKey();

Widget build(BuildContext context) {
final grouped = buildGroups(blocks, isStreaming);
final lastUserIndex = findLastUserIndex(grouped);
final hasPlaceholder = placeholderHeight > 0 && lastUserIndex >= 0;

return ListView.builder(
controller: scrollController,
itemCount: grouped.length + (hasPlaceholder ? 1 : 0),
itemBuilder: (context, index) {
if (hasPlaceholder && index == lastUserIndex) {
// Wrapper với minHeight
return Container(
key: liveKey,
constraints: BoxConstraints(minHeight: placeholderHeight),
child: Column(
children: [
for (var i = lastUserIndex; i < grouped.length; i++)
buildItem(grouped[i]),
if (isStreaming) LoadingDots(),
],
),
);
}

if (hasPlaceholder && index > lastUserIndex) return SizedBox.shrink();

return buildItem(grouped[index]);
},
);

// Scroll sau build
WidgetsBinding.instance.addPostFrameCallback((_) {
if (hasPlaceholder && liveKey.currentContext != null) {
Scrollable.ensureVisible(liveKey.currentContext!);
}
});
}
}

Checklist

  • Tính placeholderHeight khi submit message
  • Tìm lastUserIndex trong grouped items
  • Wrap từ last user trở đi trong minHeight container
  • scrollIntoView đến wrapper
  • Reset placeholderHeight = 0 khi F5 / load history / switch session
  • Không apply placeholder khi không có user message