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

381 lines
13 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;
bool _autoScrollQueued = false;
int _lastMessageCount = 0;
List<String> _lastMessageSigs = const [];
static const double _bottomThreshold = 56;
bool _isAtBottom() {
if (!_scrollController.hasClients) return true;
final pos = _scrollController.position;
return pos.maxScrollExtent - pos.pixels <= _bottomThreshold;
}
void _scheduleAutoScroll() {
if (_autoScrollQueued) return;
_autoScrollQueued = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_autoScrollQueued = false;
if (!mounted || !_scrollController.hasClients) return;
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
});
}
@override
Widget build(BuildContext context) {
return Consumer<ChatProvider>(
builder: (context, chatProvider, _) {
final currentMessages = chatProvider.messages;
final currentSigs = currentMessages.map((m) => "${m.role}:${m.content}").toList(growable: false);
final messagesChanged = currentMessages.length != _lastMessageCount ||
currentSigs.length != _lastMessageSigs.length ||
!_listEquals(currentSigs, _lastMessageSigs);
if (messagesChanged && currentMessages.isNotEmpty) {
if (_isAtBottom()) _scheduleAutoScroll();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_lastMessageCount = currentMessages.length;
_lastMessageSigs = currentSigs;
});
} else if (currentMessages.isEmpty) {
_lastMessageCount = 0;
_lastMessageSigs = const [];
}
final entries = _buildEntries(currentMessages);
return Stack(
children: [
ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: SingleChildScrollView(
controller: _scrollController,
physics: const ClampingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var index = 0; index < entries.length; index++)
Padding(
key: ValueKey('${entries[index].stableKey}#$index'),
padding: const EdgeInsets.only(bottom: 12),
child: _buildBubble(context, chatProvider, entries[index], index, entries.length),
),
],
),
),
),
_JumpToBottomButton(scrollController: _scrollController),
],
);
},
);
}
Widget _buildBubble(BuildContext context, ChatProvider chatProvider, _ChatEntry entry, int index, int total) {
final pending = chatProvider.pendingPermission;
final isThisPending = pending != null && index == total - 1 && entry is _ToolEntry && entry.toolName == pending.toolName;
if (entry is _MessageEntry) {
final msg = entry.message;
if (msg.role == "user") return UserBubble(content: msg.content, attachments: msg.attachments);
if (msg.role == "assistant") return AssistantBubble(content: msg.content);
return Text(msg.content);
}
if (entry is _ToolEntry) {
return ToolBubble(toolName: entry.toolName, toolInput: entry.toolInput, result: entry.result, isPendingPermission: isThisPending);
}
return const SizedBox.shrink();
}
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);
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;
}
final (toolName, _) = ToolBubble.parseContent(msg.content);
result.add(_ToolEntry(toolName: toolName));
i++;
} else {
result.add(_MessageEntry(msg));
i++;
}
}
return result;
}
bool _listEquals(List<String> a, List<String> b) {
if (identical(a, b)) return true;
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
}
sealed class _ChatEntry {
String get stableKey;
}
class _MessageEntry extends _ChatEntry {
_MessageEntry(this.message);
final Message message;
@override
String get stableKey => 'msg:${message.role}:${message.content}';
}
class _ToolEntry extends _ChatEntry {
_ToolEntry({required this.toolName, this.toolInput, this.result});
final String toolName;
final Map<String, dynamic>? toolInput;
final String? result;
@override
String get stableKey => 'tool:$toolName:${toolInput?.toString() ?? ""}:${result ?? ""}:${identityHashCode(this)}';
}
class _JumpToBottomButton extends StatefulWidget {
final ScrollController scrollController;
const _JumpToBottomButton({required this.scrollController});
@override
State<_JumpToBottomButton> createState() => _JumpToBottomButtonState();
}
class _JumpToBottomButtonState extends State<_JumpToBottomButton> {
bool _show = false;
static const double _bottomThreshold = 56;
@override
void initState() {
super.initState();
widget.scrollController.addListener(_onScroll);
}
@override
void dispose() {
widget.scrollController.removeListener(_onScroll);
super.dispose();
}
void _onScroll() {
if (!widget.scrollController.hasClients) return;
final pos = widget.scrollController.position;
final atBottom = pos.maxScrollExtent - pos.pixels <= _bottomThreshold;
final shouldShow = !atBottom;
if (_show != shouldShow) {
setState(() => _show = shouldShow);
}
}
void _jump() {
if (!widget.scrollController.hasClients) return;
widget.scrollController.jumpTo(widget.scrollController.position.maxScrollExtent);
}
@override
Widget build(BuildContext context) {
if (!_show) return const SizedBox.shrink();
return Positioned(
bottom: 16,
right: 16,
child: GestureDetector(
onTap: _jump,
child: Container(
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: Icon(LucideIcons.arrowDown, size: 16, color: Theme.of(context).colorScheme.foreground),
),
),
);
}
}
class ChatScrollBar extends StatefulWidget {
final ScrollController controller;
const ChatScrollBar({super.key, required this.controller});
@override
State<ChatScrollBar> createState() => _ChatScrollBarState();
}
class _ChatScrollBarState extends State<ChatScrollBar> {
bool _hovering = false;
bool _scrolling = false;
DateTime _lastScroll = DateTime.fromMillisecondsSinceEpoch(0);
double? _stableThumbHeight;
double? _lastThumbHeight;
double? _lastThumbTop;
bool _lastVisible = false;
@override
void initState() {
super.initState();
widget.controller.addListener(_onScroll);
}
@override
void didUpdateWidget(covariant ChatScrollBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
oldWidget.controller.removeListener(_onScroll);
widget.controller.addListener(_onScroll);
}
}
void _onScroll() {
_lastScroll = DateTime.now();
if (!_scrolling && mounted) {
setState(() => _scrolling = true);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (DateTime.now().difference(_lastScroll).inMilliseconds >= 500 && _scrolling) {
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) {
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 totalHeight = constraints.maxHeight;
final liveThumbHeight = ((pos.viewportDimension / (pos.viewportDimension + maxScroll)) * totalHeight).clamp(32.0, totalHeight);
final thumbHeight = _stableThumbHeight ?? liveThumbHeight;
if (!_scrolling && (_stableThumbHeight == null || (_stableThumbHeight! - liveThumbHeight).abs() > 2)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (_stableThumbHeight != liveThumbHeight) {
setState(() => _stableThumbHeight = liveThumbHeight);
}
});
}
final thumbTop = (pos.pixels / maxScroll) * (totalHeight - thumbHeight);
if (_lastThumbHeight != thumbHeight || _lastThumbTop != thumbTop || _lastVisible != visible) {
_lastThumbHeight = thumbHeight;
_lastThumbTop = thumbTop;
_lastVisible = visible;
}
return GestureDetector(
behavior: HitTestBehavior.translucent,
onVerticalDragStart: (details) {
final trackTravel = (totalHeight - thumbHeight).clamp(1.0, double.infinity);
final nextThumbTop = (details.localPosition.dy - thumbHeight / 2).clamp(0.0, trackTravel);
final nextPixels = (nextThumbTop / trackTravel) * maxScroll;
widget.controller.jumpTo(nextPixels.clamp(0.0, maxScroll));
},
onVerticalDragUpdate: (details) {
final trackTravel = (totalHeight - thumbHeight).clamp(1.0, double.infinity);
final nextThumbTop = (details.localPosition.dy - thumbHeight / 2).clamp(0.0, trackTravel);
final nextPixels = (nextThumbTop / trackTravel) * maxScroll;
widget.controller.jumpTo(nextPixels.clamp(0.0, maxScroll));
},
onTapDown: (details) {
final trackTravel = (totalHeight - thumbHeight).clamp(1.0, double.infinity);
final centerTop = (details.localPosition.dy - thumbHeight / 2).clamp(0.0, trackTravel);
final nextPixels = (centerTop / trackTravel) * maxScroll;
widget.controller.jumpTo(nextPixels.clamp(0.0, maxScroll));
},
child: Stack(
children: [
Positioned.fill(
child: const SizedBox.shrink(),
),
Positioned(
top: thumbTop,
left: 0,
right: 0,
height: thumbHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.mutedForeground.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(4),
),
),
),
),
],
),
);
},
),
),
);
}
}