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(); _scrollToBottom();
} }
void _scrollToBottom() { void _scrollToBottom({bool jump = false}) {
WidgetsBinding.instance.addPostFrameCallback((_) {
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((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) { if (_scrollController.hasClients) {
_scrollController.animateTo( _scrollController.jumpTo(
_scrollController.position.maxScrollExtent + 80, _scrollController.position.maxScrollExtent + 80,
);
}
});
} else {
_scrollController.animateTo(
target,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.easeOut, curve: Curves.easeOut,
); );
@ -454,7 +466,12 @@ class _ChatPageState extends ConsumerState<ChatPage> {
final chatState = ref.watch(chatProvider); final chatState = ref.watch(chatProvider);
// Auto-scroll when messages change // 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( return Scaffold(
drawer: const ConversationDrawer(), drawer: const ConversationDrawer(),
@ -510,20 +527,20 @@ class _ChatPageState extends ConsumerState<ChatPage> {
), ),
), ),
// Timeline message list // Timeline message list + floating input
Expanded( Expanded(
child: chatState.messages.isEmpty child: Stack(
children: [
// Messages fill the entire area
chatState.messages.isEmpty
? _buildEmptyState() ? _buildEmptyState()
: ListView.builder( : ListView.builder(
controller: _scrollController, controller: _scrollController,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), // Bottom padding leaves room for the floating input pill
// Extra item for the "working" placeholder when streaming padding: const EdgeInsets.fromLTRB(12, 8, 12, 80),
// and the last message is still a user message (no agent
// response has arrived yet).
itemCount: chatState.messages.length + itemCount: chatState.messages.length +
(_needsWorkingNode(chatState) ? 1 : 0), (_needsWorkingNode(chatState) ? 1 : 0),
itemBuilder: (context, index) { itemBuilder: (context, index) {
// Render the virtual "working" node at the end
if (index == chatState.messages.length && if (index == chatState.messages.length &&
_needsWorkingNode(chatState)) { _needsWorkingNode(chatState)) {
return TimelineEventNode( return TimelineEventNode(
@ -545,10 +562,16 @@ class _ChatPageState extends ConsumerState<ChatPage> {
); );
}, },
), ),
// Floating input pill at bottom
Positioned(
left: 12,
right: 12,
bottom: 8,
child: _buildInputArea(chatState),
),
],
),
), ),
// Input row
_buildInputArea(chatState),
], ],
), ),
), ),
@ -592,61 +615,83 @@ class _ChatPageState extends ConsumerState<ChatPage> {
final isStreaming = chatState.isStreaming && !isAwaitingApproval; final isStreaming = chatState.isStreaming && !isAwaitingApproval;
return Container( return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surface, color: AppColors.surface.withOpacity(0.92),
border: Border(top: BorderSide(color: AppColors.surfaceLight.withOpacity(0.5))), 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( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (_pendingAttachments.isNotEmpty) _buildAttachmentPreview(), if (_pendingAttachments.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
child: _buildAttachmentPreview(),
),
Row( Row(
children: [ children: [
if (!isStreaming) if (!isStreaming)
IconButton( Padding(
icon: const Icon(Icons.add_circle_outline), padding: const EdgeInsets.only(left: 4),
child: IconButton(
icon: const Icon(Icons.add_circle_outline, size: 22),
tooltip: '添加图片', tooltip: '添加图片',
onPressed: isAwaitingApproval ? null : _showAttachmentOptions, onPressed: isAwaitingApproval ? null : _showAttachmentOptions,
), ),
),
Expanded( Expanded(
child: TextField( child: TextField(
controller: _messageController, controller: _messageController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: isStreaming ? '追加指令...' : '输入指令...', hintText: isStreaming ? '追加指令...' : '输入指令...',
hintStyle: TextStyle(color: AppColors.textMuted), hintStyle: TextStyle(color: AppColors.textMuted),
border: const OutlineInputBorder( border: InputBorder.none,
borderRadius: BorderRadius.all(Radius.circular(24)), contentPadding: EdgeInsets.only(
left: isStreaming ? 16 : 4,
right: 4,
top: 12,
bottom: 12,
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
), ),
textInputAction: TextInputAction.send, textInputAction: TextInputAction.send,
onSubmitted: (_) => isStreaming ? _inject() : _send(), onSubmitted: (_) => isStreaming ? _inject() : _send(),
enabled: !isAwaitingApproval, enabled: !isAwaitingApproval,
), ),
), ),
const SizedBox(width: 8),
if (isStreaming) if (isStreaming)
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.send, color: AppColors.info), icon: const Icon(Icons.send, color: AppColors.info, size: 20),
tooltip: '追加指令', tooltip: '追加指令',
onPressed: _inject, onPressed: _inject,
), ),
IconButton( Padding(
icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error), padding: const EdgeInsets.only(right: 4),
child: IconButton(
icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error, size: 20),
tooltip: '停止', tooltip: '停止',
onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(), onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(),
), ),
),
], ],
) )
else else
IconButton( Padding(
icon: const Icon(Icons.send), padding: const EdgeInsets.only(right: 4),
child: IconButton(
icon: const Icon(Icons.send, size: 20),
onPressed: isAwaitingApproval ? null : _send, onPressed: isAwaitingApproval ? null : _send,
), ),
),
], ],
), ),
], ],