The-Agency/lib/ui/widgets/chat/chat_view.dart

383 lines
12 KiB
Dart

import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../src/session/session_types.dart";
import "../../providers/chat_provider.dart";
import "bubbles/assistant_bubble.dart";
import "bubbles/tool_bubble.dart";
import "bubbles/user_bubble.dart";
class ChatView extends StatefulWidget {
final ScrollController scrollController;
const ChatView({super.key, required this.scrollController});
@override
State<ChatView> createState() => _ChatViewState();
}
class _ChatViewState extends State<ChatView> {
ScrollController get _scrollController => widget.scrollController;
List<String> _previousMessageContents = [];
bool _isUserScrolling = false;
DateTime? _lastScrollTime;
bool _showJumpToBottom = false;
bool _hasNewMessagesWhileScrolledAway = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_handleScroll);
}
@override
void dispose() {
_scrollController.removeListener(_handleScroll);
super.dispose();
}
void _handleScroll() {
_lastScrollTime = DateTime.now();
_isUserScrolling = true;
if (_scrollController.hasClients) {
final position = _scrollController.position;
final isFarFromBottom = position.pixels < position.maxScrollExtent - 200;
if (isFarFromBottom != _showJumpToBottom) {
setState(() {
_showJumpToBottom = isFarFromBottom;
});
}
if (!isFarFromBottom) {
setState(() {
_hasNewMessagesWhileScrolledAway = false;
});
}
}
Future.delayed(const Duration(milliseconds: 150), () {
if (_lastScrollTime != null &&
DateTime.now().difference(_lastScrollTime!) >= const Duration(milliseconds: 150)) {
if (mounted) {
setState(() {
_isUserScrolling = false;
});
}
}
});
}
bool _isNearBottom() {
if (!_scrollController.hasClients) return false;
final position = _scrollController.position;
return position.pixels >= position.maxScrollExtent - 150;
}
void _jumpToBottom() {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
setState(() {
_showJumpToBottom = false;
_hasNewMessagesWhileScrolledAway = false;
});
}
}
@override
Widget build(BuildContext context) {
return Consumer<ChatProvider>(
builder: (context, chatProvider, _) {
final currentMessages = chatProvider.messages;
bool messagesChanged = false;
if (currentMessages.length != _previousMessageContents.length) {
messagesChanged = true;
} else {
for (int i = 0; i < currentMessages.length; i++) {
if (currentMessages[i].content != _previousMessageContents[i]) {
messagesChanged = true;
break;
}
}
}
if (messagesChanged && currentMessages.isNotEmpty) {
final nearBottom = _isNearBottom();
if (nearBottom && !_isUserScrolling) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
_hasNewMessagesWhileScrolledAway = false;
} else if (!nearBottom) {
_hasNewMessagesWhileScrolledAway = true;
}
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_previousMessageContents = currentMessages.map((m) => m.content).toList();
});
final entries = _buildEntries(currentMessages);
return Stack(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: ListView.builder(
controller: _scrollController,
physics: const ClampingScrollPhysics(),
itemCount: entries.length,
itemBuilder: (context, index) {
final entry = entries[index];
final pending = chatProvider.pendingPermission;
final isThisPending = pending != null &&
index == entries.length - 1 &&
entry is _ToolEntry &&
entry.toolName == pending.toolName;
Widget bubble;
if (entry is _MessageEntry) {
final msg = entry.message;
if (msg.role == "user") {
bubble = UserBubble(content: msg.content);
} else if (msg.role == "assistant") {
bubble = AssistantBubble(content: msg.content);
} else {
bubble = Text(msg.content);
}
} else if (entry is _ToolEntry) {
bubble = ToolBubble(
toolName: entry.toolName,
toolInput: entry.toolInput,
result: entry.result,
isPendingPermission: isThisPending,
);
} else {
bubble = const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: bubble,
);
},
),
),
),
if (_showJumpToBottom && _hasNewMessagesWhileScrolledAway)
Positioned(
bottom: 16,
right: 16,
child: GestureDetector(
onTap: _jumpToBottom,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.background.withOpacity(0.9),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: const Color(0xFF000000).withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.arrowDown,
size: 16,
color: Theme.of(context).colorScheme.foreground,
),
const SizedBox(width: 6),
Text(
"New messages",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.foreground,
),
),
],
),
),
),
),
],
);
},
);
}
// merge consecutive tool call + result messages into single entries
List<_ChatEntry> _buildEntries(List<Message> messages) {
final result = <_ChatEntry>[];
int i = 0;
while (i < messages.length) {
final msg = messages[i];
if (msg.role == "tool") {
final firstLine = msg.content.split("\n").first.trim();
if (firstLine.endsWith(" call")) {
final (toolName, toolInput) = ToolBubble.parseContent(msg.content);
// check if next message is the matching result
String? toolResult;
if (i + 1 < messages.length) {
final next = messages[i + 1];
final nextFirst = next.content.split("\n").first.trim();
if (next.role == "tool" && nextFirst == "$toolName result") {
final body = next.content.indexOf("\n");
toolResult = body != -1 ? next.content.substring(body + 1).trim() : null;
i++;
}
}
result.add(_ToolEntry(
toolName: toolName,
toolInput: toolInput,
result: toolResult,
));
i++;
continue;
}
// orphan result or unknown tool message — skip it
// (already consumed as part of a call above, or genuinely standalone)
final (toolName, _) = ToolBubble.parseContent(msg.content);
result.add(_ToolEntry(toolName: toolName));
i++;
} else {
result.add(_MessageEntry(msg));
i++;
}
}
return result;
}
}
sealed class _ChatEntry {}
class _MessageEntry extends _ChatEntry {
_MessageEntry(this.message);
final Message message;
}
class _ToolEntry extends _ChatEntry {
_ToolEntry({required this.toolName, this.toolInput, this.result});
final String toolName;
final Map<String, dynamic>? toolInput;
final String? result;
}
class FullHeightScrollbar extends StatefulWidget {
final ScrollController controller;
const FullHeightScrollbar({super.key, required this.controller});
@override
State<FullHeightScrollbar> createState() => _FullHeightScrollbarState();
}
class _FullHeightScrollbarState extends State<FullHeightScrollbar> {
bool _hovering = false;
bool _scrolling = false;
DateTime _lastScroll = DateTime.fromMillisecondsSinceEpoch(0);
@override
void initState() {
super.initState();
widget.controller.addListener(_onScroll);
}
void _onScroll() {
_lastScroll = DateTime.now();
setState(() => _scrolling = true);
Future.delayed(const Duration(milliseconds: 800), () {
if (!mounted) return;
if (DateTime.now().difference(_lastScroll).inMilliseconds >= 800) {
setState(() => _scrolling = false);
}
});
}
@override
void dispose() {
widget.controller.removeListener(_onScroll);
super.dispose();
}
@override
Widget build(BuildContext context) {
final visible = _hovering || _scrolling;
return MouseRegion(
onEnter: (_) => setState(() => _hovering = true),
onExit: (_) => setState(() => _hovering = false),
child: AnimatedOpacity(
opacity: visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: LayoutBuilder(
builder: (context, constraints) {
final totalHeight = constraints.maxHeight;
if (!widget.controller.hasClients) return const SizedBox.shrink();
final pos = widget.controller.position;
final maxScroll = pos.maxScrollExtent;
if (maxScroll <= 0) return const SizedBox.shrink();
final viewportFraction = pos.viewportDimension / (pos.viewportDimension + maxScroll);
final thumbHeight = (viewportFraction * totalHeight).clamp(32.0, totalHeight);
final scrollFraction = pos.pixels / maxScroll;
final thumbTop = scrollFraction * (totalHeight - thumbHeight);
final color = Theme.of(context).colorScheme.mutedForeground.withValues(alpha: 0.4);
return Stack(
children: [
Positioned(
top: thumbTop,
left: 2,
right: 2,
height: thumbHeight,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(4),
),
),
),
],
);
},
),
),
);
}
}