381 lines
13 KiB
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),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|