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:
parent
1f1bf18a75
commit
ed39518a71
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue