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ước và từ 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
| Scenario | placeholderHeight | Behavior |
|---|---|---|
| Send message mới | > 0 | Placeholder active, scroll to top |
| F5 / load history | 0 | Không placeholder, render bình thường |
| Agent done | Reset về 0 | Placeholder 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
placeholderHeightkhi submit message - Tìm
lastUserIndextrong grouped items - Wrap từ last user trở đi trong
minHeightcontainer -
scrollIntoViewđến wrapper - Reset
placeholderHeight = 0khi F5 / load history / switch session - Không apply placeholder khi không có user message