Update project structure and enhance functionality with new features and dependencies

This commit is contained in:
ImBenji
2026-04-14 03:31:29 +01:00
parent 0b6b604c56
commit 3588783001
63 changed files with 10565 additions and 789 deletions
@@ -111,7 +111,7 @@ class ToolBubbleBase extends StatelessWidget {
child: Button.outline(
leading: Icon(LucideIcons.check).iconSmall,
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.allowOnce),
child: Text("Allow").small,
child: Text("Yes").small,
),
),
@@ -121,7 +121,7 @@ class ToolBubbleBase extends StatelessWidget {
child: Button.outline(
leading: Icon(LucideIcons.checkCheck).iconSmall,
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.allowAlways),
child: Text("Allow always").small,
child: Text("Yes, for this session").small,
),
),
@@ -131,7 +131,7 @@ class ToolBubbleBase extends StatelessWidget {
child: Button.destructive(
leading: Icon(LucideIcons.x).iconSmall,
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.reject),
child: Text("Reject").small,
child: Text("No").small,
),
),
+47 -5
View File
@@ -1,18 +1,60 @@
import "dart:typed_data";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../models/attachment.dart";
import "../attachment_preview.dart";
import "../../../../src/session/session_types.dart";
class UserBubble extends StatelessWidget {
const UserBubble({super.key, required this.content});
const UserBubble({
super.key,
required this.content,
this.attachments,
});
final String content;
final List<MessageAttachment>? attachments;
@override
Widget build(BuildContext context) {
final atts = attachments;
return Align(
alignment: Alignment.centerRight,
child: OutlinedContainer(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
backgroundColor: Theme.of(context).colorScheme.border,
child: SelectableText(content),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (atts != null && atts.isNotEmpty) ...[
SingleChildScrollView(
scrollDirection: Axis.horizontal,
reverse: true,
child: Row(
children: [
for (final att in atts)
Padding(
padding: const EdgeInsets.only(left: 8),
child: AttachmentItem(
attachment: Attachment(
name: att.name,
mimeType: att.mimeType,
data: Uint8List.fromList(att.data),
),
onRemove: () {},
),
),
],
),
),
const Gap(6),
],
OutlinedContainer(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
backgroundColor: Theme.of(context).colorScheme.border,
child: SelectableText(content),
),
],
),
);
}
+127 -12
View File
@@ -214,6 +214,7 @@ class _ChatBoxState extends State<ChatBox> {
Widget _right(BuildContext context) {
final settings = context.read<SettingsProvider>();
final selectedModel = settings.normalizeModelId(settings.settings.model);
final isLoading = context.watch<ChatProvider>().isLoading;
return SizedBox(
height: 38,
@@ -256,16 +257,23 @@ class _ChatBoxState extends State<ChatBox> {
AspectRatio(
aspectRatio: 1,
child: AgcSecondaryButton(
enabled: _controller.text.isNotEmpty,
onPressed: () {
final text = _controller.text.trim();
if (text.isEmpty) return;
context.read<HomeCoordinator>().sendMessage(text);
_controller.clear();
},
child: Icon(LucideIcons.arrowUp),
),
child: (isLoading && _controller.text.isEmpty && _attachments.isEmpty)
? AgcSecondaryButton(
onPressed: () => context.read<ChatProvider>().stopGenerating(),
child: Icon(LucideIcons.octagonX),
)
: AgcSecondaryButton(
enabled: _controller.text.isNotEmpty || _attachments.isNotEmpty,
onPressed: () {
final text = _controller.text.trim();
if (text.isEmpty && _attachments.isEmpty) return;
final toSend = List.of(_attachments);
context.read<HomeCoordinator>().sendMessage(text, attachments: toSend);
_controller.clear();
setState(() => _attachments.clear());
},
child: Icon(LucideIcons.arrowUp),
),
),
],
),
@@ -343,6 +351,7 @@ class _ChatBoxState extends State<ChatBox> {
return Focus(
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.keyV &&
(HardwareKeyboard.instance.isControlPressed ||
@@ -363,9 +372,11 @@ class _ChatBoxState extends State<ChatBox> {
return KeyEventResult.handled;
} else {
final text = _controller.text.trim();
if (text.isNotEmpty) {
context.read<HomeCoordinator>().sendMessage(text);
if (text.isNotEmpty || _attachments.isNotEmpty) {
final toSend = List.of(_attachments);
context.read<HomeCoordinator>().sendMessage(text, attachments: toSend);
_controller.clear();
setState(() => _attachments.clear());
}
return KeyEventResult.handled;
}
@@ -374,6 +385,14 @@ class _ChatBoxState extends State<ChatBox> {
return KeyEventResult.ignored;
},
child: OutlinedContainer(
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 16,
spreadRadius: 2,
offset: Offset(0, 4),
),
],
child: ButtonGroup.vertical(
expands: true,
children: [
@@ -449,7 +468,103 @@ class _ChatBoxState extends State<ChatBox> {
},
),
const SizedBox(height: 6),
_PermissionModeSelector(),
],
);
}
}
class _PermissionModeSelector extends StatelessWidget {
const _PermissionModeSelector();
static const _modes = [
("default", "Ask Always"),
("acceptEdits", "Accept Edits"),
];
@override
Widget build(BuildContext context) {
final chat = context.watch<ChatProvider>();
final current = chat.threadPermissionMode;
final theme = Theme.of(context);
final mutedFg = theme.colorScheme.mutedForeground;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (int i = 0; i < _modes.length; i++) ...[
if (i > 0) const SizedBox(width: 4),
_ModeChip(
label: _modes[i].$2,
selected: current == _modes[i].$1,
mutedFg: mutedFg,
onTap: () => chat.setThreadPermissionMode(_modes[i].$1),
),
],
],
);
}
}
class _ModeChip extends StatefulWidget {
const _ModeChip({
required this.label,
required this.selected,
required this.mutedFg,
required this.onTap,
});
final String label;
final bool selected;
final Color mutedFg;
final VoidCallback onTap;
@override
State<_ModeChip> createState() => _ModeChipState();
}
class _ModeChipState extends State<_ModeChip> {
bool _hovering = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final fg = widget.selected
? theme.colorScheme.foreground
: widget.mutedFg;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovering = true),
onExit: (_) => setState(() => _hovering = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: widget.selected
? theme.colorScheme.secondary
: _hovering
? theme.colorScheme.secondary.withValues(alpha: 0.5)
: Colors.transparent,
borderRadius: BorderRadius.circular(theme.radiusSm),
),
child: Text(
widget.label,
style: TextStyle(fontSize: 11, color: fg, fontWeight: widget.selected ? FontWeight.w600 : FontWeight.normal),
),
),
),
);
}
}
+234 -236
View File
@@ -18,219 +18,97 @@ class ChatView extends StatefulWidget {
class _ChatViewState extends State<ChatView> {
ScrollController get _scrollController => widget.scrollController;
List<String> _previousMessageContents = [];
bool _isUserScrolling = false;
DateTime? _lastScrollTime;
bool _showJumpToBottom = false;
bool _hasNewMessagesWhileScrolledAway = false;
bool _autoScrollQueued = false;
int _lastMessageCount = 0;
List<String> _lastMessageSigs = const [];
@override
void initState() {
super.initState();
_scrollController.addListener(_handleScroll);
static const double _bottomThreshold = 56;
bool _isAtBottom() {
if (!_scrollController.hasClients) return true;
final pos = _scrollController.position;
return pos.maxScrollExtent - pos.pixels <= _bottomThreshold;
}
@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;
});
}
}
void _scheduleAutoScroll() {
if (_autoScrollQueued) return;
_autoScrollQueued = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_autoScrollQueued = false;
if (!mounted || !_scrollController.hasClients) return;
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
});
}
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;
}
}
}
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) {
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;
}
if (_isAtBottom()) _scheduleAutoScroll();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_lastMessageCount = currentMessages.length;
_lastMessageSigs = currentSigs;
});
} else if (currentMessages.isEmpty) {
_lastMessageCount = 0;
_lastMessageSigs = const [];
}
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,
);
},
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),
),
],
),
),
),
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,
),
),
],
),
),
),
),
_JumpToBottomButton(scrollController: _scrollController),
],
);
},
);
}
// merge consecutive tool call + result messages into single entries
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;
@@ -238,11 +116,8 @@ class _ChatViewState extends State<ChatView> {
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];
@@ -253,18 +128,10 @@ class _ChatViewState extends State<ChatView> {
i++;
}
}
result.add(_ToolEntry(
toolName: toolName,
toolInput: toolInput,
result: toolResult,
));
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++;
@@ -275,13 +142,27 @@ class _ChatViewState extends State<ChatView> {
}
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 {}
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 {
@@ -289,22 +170,97 @@ class _ToolEntry extends _ChatEntry {
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();
String get stableKey => 'tool:$toolName:${toolInput?.toString() ?? ""}:${result ?? ""}:${identityHashCode(this)}';
}
class _FullHeightScrollbarState extends State<FullHeightScrollbar> {
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() {
@@ -312,13 +268,23 @@ class _FullHeightScrollbarState extends State<FullHeightScrollbar> {
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();
setState(() => _scrolling = true);
Future.delayed(const Duration(milliseconds: 800), () {
if (!_scrolling && mounted) {
setState(() => _scrolling = true);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (DateTime.now().difference(_lastScroll).inMilliseconds >= 800) {
if (DateTime.now().difference(_lastScroll).inMilliseconds >= 500 && _scrolling) {
setState(() => _scrolling = false);
}
});
@@ -333,7 +299,6 @@ class _FullHeightScrollbarState extends State<FullHeightScrollbar> {
@override
Widget build(BuildContext context) {
final visible = _hovering || _scrolling;
return MouseRegion(
onEnter: (_) => setState(() => _hovering = true),
onExit: (_) => setState(() => _hovering = false),
@@ -342,38 +307,71 @@ class _FullHeightScrollbarState extends State<FullHeightScrollbar> {
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 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;
}
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),
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),
),
),
),
),
),
],
],
),
);
},
),
+51 -15
View File
@@ -6,6 +6,9 @@ import "../../../src/permissions/permission_types.dart";
import "../../../src/session/session_types.dart";
import "advisor_message.dart";
import "../common/button.dart";
import "dart:typed_data";
import "attachment_preview.dart";
import "../../models/attachment.dart";
class MessageBubble extends StatelessWidget {
const MessageBubble({
@@ -28,20 +31,53 @@ class MessageBubble extends StatelessWidget {
if (isUser) {
final atts = message.attachments;
return Align(
alignment: Alignment.centerRight,
child: OutlinedContainer(
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
backgroundColor: theme.colorScheme.border,
child: MarkdownBody(
data: message.content,
selectable: true,
shrinkWrap: true,
styleSheet: _toolMarkdownStyleSheet(context),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (atts != null && atts.isNotEmpty) ...[
SingleChildScrollView(
scrollDirection: Axis.horizontal,
reverse: true,
child: Row(
children: [
for (final att in atts)
Padding(
padding: EdgeInsets.only(left: 8),
child: AttachmentItem(
attachment: Attachment(
name: att.name,
mimeType: att.mimeType,
data: Uint8List.fromList(att.data),
),
onRemove: () {},
),
),
],
),
),
Gap(6),
],
OutlinedContainer(
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
backgroundColor: theme.colorScheme.border,
child: MarkdownBody(
data: message.content,
selectable: true,
shrinkWrap: true,
styleSheet: _toolMarkdownStyleSheet(context),
),
),
],
),
);
} else if (isAssistant) {
@@ -94,21 +130,21 @@ class MessageBubble extends StatelessWidget {
AgcSecondaryButton(
onPressed: () => onPermissionDecision?.call(PermissionDecision.allowOnce),
child: Text("Allow").small,
child: Text("Yes").small,
),
Gap(8),
AgcGhostButton(
onPressed: () => onPermissionDecision?.call(PermissionDecision.allowAlways),
child: Text("Allow always").small,
child: Text("Yes, for this session").small,
),
Gap(8),
AgcGhostButton(
onPressed: () => onPermissionDecision?.call(PermissionDecision.reject),
child: Text("Reject").small,
child: Text("No").small,
),
],