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();
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in New Issue