feat: floating pill input bar + auto-scroll on history load

Input area redesign (ChatGPT/Claude App style):
- Replace fixed bottom bar with floating pill overlay using Stack+Positioned
- Semi-transparent background (surface 92% opacity) with rounded corners (28px)
- Drop shadow for depth separation from content
- Remove inner TextField border (InputBorder.none) for cleaner look
- ListView bottom padding increased to 80px to leave room under the pill
- Input pill floats 12px from edges, 8px from bottom

History scroll fix:
- Add jump parameter to _scrollToBottom() for instant positioning
- When loading conversation history (empty→many messages), use jumpTo
  instead of animateTo to avoid incomplete scroll on large message lists
- Double-frame jumpTo ensures layout settles before final scroll position

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-28 05:15:18 -08:00
parent 1f1bf18a75
commit ed39518a71
1 changed files with 106 additions and 61 deletions

View File

@ -53,11 +53,23 @@ class _ChatPageState extends ConsumerState<ChatPage> {
_scrollToBottom();
}
void _scrollToBottom() {
void _scrollToBottom({bool jump = false}) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
if (!_scrollController.hasClients) return;
final target = _scrollController.position.maxScrollExtent + 80;
if (jump) {
_scrollController.jumpTo(target);
// Second frame: layout may still be settling for large message lists
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.jumpTo(
_scrollController.position.maxScrollExtent + 80,
);
}
});
} else {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent + 80,
target,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
@ -454,7 +466,12 @@ class _ChatPageState extends ConsumerState<ChatPage> {
final chatState = ref.watch(chatProvider);
// Auto-scroll when messages change
ref.listen(chatProvider, (_, __) => _scrollToBottom());
ref.listen(chatProvider, (prev, next) {
// Jump (no animation) when loading a conversation history
final wasEmpty = prev?.messages.isEmpty ?? true;
final nowHasMany = next.messages.length > 1;
_scrollToBottom(jump: wasEmpty && nowHasMany);
});
return Scaffold(
drawer: const ConversationDrawer(),
@ -510,45 +527,51 @@ class _ChatPageState extends ConsumerState<ChatPage> {
),
),
// Timeline message list
// Timeline message list + floating input
Expanded(
child: chatState.messages.isEmpty
? _buildEmptyState()
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
// Extra item for the "working" placeholder when streaming
// and the last message is still a user message (no agent
// response has arrived yet).
itemCount: chatState.messages.length +
(_needsWorkingNode(chatState) ? 1 : 0),
itemBuilder: (context, index) {
// Render the virtual "working" node at the end
if (index == chatState.messages.length &&
_needsWorkingNode(chatState)) {
return TimelineEventNode(
status: NodeStatus.active,
label: '处理中...',
isFirst: false,
isLast: false,
);
}
final isRealLast =
index == chatState.messages.length - 1;
return _buildTimelineNode(
chatState.messages[index],
chatState,
isFirst: index == 0,
isLast: isRealLast &&
!chatState.isStreaming &&
!_needsWorkingNode(chatState),
);
},
),
child: Stack(
children: [
// Messages fill the entire area
chatState.messages.isEmpty
? _buildEmptyState()
: ListView.builder(
controller: _scrollController,
// Bottom padding leaves room for the floating input pill
padding: const EdgeInsets.fromLTRB(12, 8, 12, 80),
itemCount: chatState.messages.length +
(_needsWorkingNode(chatState) ? 1 : 0),
itemBuilder: (context, index) {
if (index == chatState.messages.length &&
_needsWorkingNode(chatState)) {
return TimelineEventNode(
status: NodeStatus.active,
label: '处理中...',
isFirst: false,
isLast: false,
);
}
final isRealLast =
index == chatState.messages.length - 1;
return _buildTimelineNode(
chatState.messages[index],
chatState,
isFirst: index == 0,
isLast: isRealLast &&
!chatState.isStreaming &&
!_needsWorkingNode(chatState),
);
},
),
// Floating input pill at bottom
Positioned(
left: 12,
right: 12,
bottom: 8,
child: _buildInputArea(chatState),
),
],
),
),
// Input row
_buildInputArea(chatState),
],
),
),
@ -592,22 +615,36 @@ class _ChatPageState extends ConsumerState<ChatPage> {
final isStreaming = chatState.isStreaming && !isAwaitingApproval;
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border(top: BorderSide(color: AppColors.surfaceLight.withOpacity(0.5))),
color: AppColors.surface.withOpacity(0.92),
borderRadius: BorderRadius.circular(28),
border: Border.all(color: AppColors.surfaceLight.withOpacity(0.6)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.25),
blurRadius: 12,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_pendingAttachments.isNotEmpty) _buildAttachmentPreview(),
if (_pendingAttachments.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
child: _buildAttachmentPreview(),
),
Row(
children: [
if (!isStreaming)
IconButton(
icon: const Icon(Icons.add_circle_outline),
tooltip: '添加图片',
onPressed: isAwaitingApproval ? null : _showAttachmentOptions,
Padding(
padding: const EdgeInsets.only(left: 4),
child: IconButton(
icon: const Icon(Icons.add_circle_outline, size: 22),
tooltip: '添加图片',
onPressed: isAwaitingApproval ? null : _showAttachmentOptions,
),
),
Expanded(
child: TextField(
@ -615,37 +652,45 @@ class _ChatPageState extends ConsumerState<ChatPage> {
decoration: InputDecoration(
hintText: isStreaming ? '追加指令...' : '输入指令...',
hintStyle: TextStyle(color: AppColors.textMuted),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(24)),
border: InputBorder.none,
contentPadding: EdgeInsets.only(
left: isStreaming ? 16 : 4,
right: 4,
top: 12,
bottom: 12,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => isStreaming ? _inject() : _send(),
enabled: !isAwaitingApproval,
),
),
const SizedBox(width: 8),
if (isStreaming)
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.send, color: AppColors.info),
icon: const Icon(Icons.send, color: AppColors.info, size: 20),
tooltip: '追加指令',
onPressed: _inject,
),
IconButton(
icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error),
tooltip: '停止',
onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(),
Padding(
padding: const EdgeInsets.only(right: 4),
child: IconButton(
icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error, size: 20),
tooltip: '停止',
onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(),
),
),
],
)
else
IconButton(
icon: const Icon(Icons.send),
onPressed: isAwaitingApproval ? null : _send,
Padding(
padding: const EdgeInsets.only(right: 4),
child: IconButton(
icon: const Icon(Icons.send, size: 20),
onPressed: isAwaitingApproval ? null : _send,
),
),
],
),