Update project structure and enhance functionality with new features and dependencies
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
],
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
|
||||
// Renders text via TextPainter directly, bypassing any theme/font overrides
|
||||
// from shadcn or other inherited themes. Use this when you need a specific
|
||||
// font (e.g. google fonts) and the theme keeps clobbering it.
|
||||
class AnaText extends StatefulWidget {
|
||||
const AnaText(
|
||||
this.text, {
|
||||
required this.style,
|
||||
this.textAlign = TextAlign.left,
|
||||
this.maxLines,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final TextStyle style;
|
||||
final TextAlign textAlign;
|
||||
final int? maxLines;
|
||||
|
||||
@override
|
||||
State<AnaText> createState() => _AnaTextState();
|
||||
}
|
||||
|
||||
class _AnaTextState extends State<AnaText> {
|
||||
int _fontVersion = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
PaintingBinding.instance.systemFonts.addListener(_onFontChange);
|
||||
}
|
||||
|
||||
void _onFontChange() {
|
||||
setState(() => _fontVersion++);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
PaintingBinding.instance.systemFonts.removeListener(_onFontChange);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomPaint(
|
||||
painter: _AnaTextPainter(
|
||||
text: widget.text,
|
||||
style: widget.style,
|
||||
textAlign: widget.textAlign,
|
||||
maxLines: widget.maxLines,
|
||||
textDirection: Directionality.of(context),
|
||||
fontVersion: _fontVersion,
|
||||
),
|
||||
|
||||
child: _AnaTextSizer(
|
||||
text: widget.text,
|
||||
style: widget.style,
|
||||
maxLines: widget.maxLines,
|
||||
textDirection: Directionality.of(context),
|
||||
fontVersion: _fontVersion,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnaTextPainter extends CustomPainter {
|
||||
_AnaTextPainter({
|
||||
required this.text,
|
||||
required this.style,
|
||||
required this.textAlign,
|
||||
required this.textDirection,
|
||||
required this.fontVersion,
|
||||
this.maxLines,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final TextStyle style;
|
||||
final TextAlign textAlign;
|
||||
final TextDirection textDirection;
|
||||
final int? maxLines;
|
||||
final int fontVersion;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(text: text, style: style),
|
||||
textAlign: textAlign,
|
||||
textDirection: textDirection,
|
||||
maxLines: maxLines,
|
||||
)..layout(maxWidth: size.width);
|
||||
|
||||
final dy = (size.height - tp.height) / 2;
|
||||
tp.paint(canvas, Offset(0, dy.clamp(0.0, double.infinity)));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_AnaTextPainter old) =>
|
||||
old.text != text ||
|
||||
old.style != style ||
|
||||
old.textAlign != textAlign ||
|
||||
old.maxLines != maxLines ||
|
||||
old.fontVersion != fontVersion;
|
||||
}
|
||||
|
||||
|
||||
// Invisible child that reports the natural text size back to the layout system
|
||||
// so CustomPaint gets constrained correctly.
|
||||
class _AnaTextSizer extends LeafRenderObjectWidget {
|
||||
const _AnaTextSizer({
|
||||
required this.text,
|
||||
required this.style,
|
||||
required this.textDirection,
|
||||
required this.fontVersion,
|
||||
this.maxLines,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final TextStyle style;
|
||||
final TextDirection textDirection;
|
||||
final int? maxLines;
|
||||
final int fontVersion;
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) => _AnaTextSizerBox(
|
||||
text: text,
|
||||
style: style,
|
||||
textDirection: textDirection,
|
||||
maxLines: maxLines,
|
||||
fontVersion: fontVersion,
|
||||
);
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, _AnaTextSizerBox renderObject) {
|
||||
renderObject
|
||||
..text = text
|
||||
..style = style
|
||||
..textDirection = textDirection
|
||||
..maxLines = maxLines
|
||||
..fontVersion = fontVersion;
|
||||
}
|
||||
}
|
||||
|
||||
class _AnaTextSizerBox extends RenderBox {
|
||||
_AnaTextSizerBox({
|
||||
required String text,
|
||||
required TextStyle style,
|
||||
required TextDirection textDirection,
|
||||
required int fontVersion,
|
||||
int? maxLines,
|
||||
}) : _text = text,
|
||||
_style = style,
|
||||
_textDirection = textDirection,
|
||||
_maxLines = maxLines,
|
||||
_fontVersion = fontVersion;
|
||||
|
||||
String _text;
|
||||
TextStyle _style;
|
||||
TextDirection _textDirection;
|
||||
int? _maxLines;
|
||||
int _fontVersion;
|
||||
|
||||
set text(String v) { if (_text == v) return; _text = v; markNeedsLayout(); }
|
||||
set style(TextStyle v) { if (_style == v) return; _style = v; markNeedsLayout(); }
|
||||
|
||||
set textDirection(TextDirection v) {
|
||||
if (_textDirection == v) return;
|
||||
_textDirection = v;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
set maxLines(int? v) { if (_maxLines == v) return; _maxLines = v; markNeedsLayout(); }
|
||||
|
||||
set fontVersion(int v) { if (_fontVersion == v) return; _fontVersion = v; markNeedsLayout(); }
|
||||
|
||||
TextPainter _buildPainter({double maxWidth = double.infinity}) {
|
||||
return TextPainter(
|
||||
text: TextSpan(text: _text, style: _style),
|
||||
textDirection: _textDirection,
|
||||
maxLines: _maxLines,
|
||||
)..layout(maxWidth: maxWidth);
|
||||
}
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicWidth(double height) => _buildPainter().width;
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicWidth(double height) => _buildPainter().width;
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicHeight(double width) =>
|
||||
_buildPainter(maxWidth: width).height;
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicHeight(double width) =>
|
||||
_buildPainter(maxWidth: width).height;
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
final tp = _buildPainter();
|
||||
size = constraints.constrain(Size(tp.width, tp.height));
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import "dart:io";
|
||||
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:google_fonts/google_fonts.dart";
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "ana_text.dart";
|
||||
|
||||
import "../../../src/project_store.dart";
|
||||
import "../../providers/home_coordinator.dart";
|
||||
import "../../providers/projects_provider.dart";
|
||||
|
||||
|
||||
class AppHeader extends StatelessWidget {
|
||||
const AppHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final coordinator = context.read<HomeCoordinator>();
|
||||
final selectedProject = context.watch<ProjectsProvider>().selectedProject;
|
||||
|
||||
final isWindows = !kIsWeb && defaultTargetPlatform == TargetPlatform.windows;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.background,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: theme.colorScheme.border, width: 1),
|
||||
),
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, top: 4, bottom: 4),
|
||||
child: _AgencyMenuBar(coordinator: coordinator),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
_ProjectNameBox(project: selectedProject),
|
||||
|
||||
if (isWindows)
|
||||
const Gap(6)
|
||||
else
|
||||
const Gap(64),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _AgencyMenuBar extends StatelessWidget {
|
||||
final HomeCoordinator coordinator;
|
||||
|
||||
const _AgencyMenuBar({required this.coordinator});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Menubar(
|
||||
border: false,
|
||||
children: [
|
||||
|
||||
MenuButton(
|
||||
subMenu: [
|
||||
MenuButton(
|
||||
leading: const Icon(LucideIcons.squarePen).iconSmall,
|
||||
onPressed: (_) => coordinator.createNewChat(),
|
||||
child: const Text("New Chat"),
|
||||
),
|
||||
MenuButton(
|
||||
leading: const Icon(LucideIcons.folderPlus).iconSmall,
|
||||
onPressed: (_) => coordinator.pickProjectDirectory(),
|
||||
child: const Text("New Project"),
|
||||
),
|
||||
],
|
||||
child: const Text("File"),
|
||||
),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _ProjectNameBox extends StatelessWidget {
|
||||
final ProjectRecord? project;
|
||||
|
||||
const _ProjectNameBox({required this.project});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final label = project?.name ?? "No project";
|
||||
|
||||
final textStyle = GoogleFonts.ibmPlexMono(
|
||||
fontSize: 11,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
);
|
||||
|
||||
return Container(
|
||||
color: theme.colorScheme.border,
|
||||
constraints: const BoxConstraints(minWidth: 100),
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: AnaText(label, style: textStyle),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,21 +36,23 @@ class _GhostButtonState extends State<AgcGhostButton> {
|
||||
bg = colorScheme.accent.withOpacity(0.5);
|
||||
}
|
||||
|
||||
final active = widget.onPressed != null;
|
||||
|
||||
return MouseRegion(
|
||||
cursor: widget.onPressed != null ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
onEnter: (_) => setState(() => _hovering = true),
|
||||
cursor: active ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
onEnter: (_) { if (active) setState(() => _hovering = true); },
|
||||
onExit: (_) => setState(() {
|
||||
_hovering = false;
|
||||
_pressing = false;
|
||||
}),
|
||||
|
||||
child: GestureDetector(
|
||||
onTapDown: (_) => setState(() => _pressing = true),
|
||||
onTapUp: (_) {
|
||||
onTapDown: active ? (_) => setState(() => _pressing = true) : null,
|
||||
onTapUp: active ? (_) {
|
||||
setState(() => _pressing = false);
|
||||
if (widget.onPressed != null) widget.onPressed!();
|
||||
},
|
||||
onTapCancel: () => setState(() => _pressing = false),
|
||||
widget.onPressed!();
|
||||
} : null,
|
||||
onTapCancel: active ? () => setState(() => _pressing = false) : null,
|
||||
|
||||
child: AnimatedContainer(
|
||||
duration: Duration(milliseconds: 80),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import "package:flutter/widgets.dart" hide Tooltip;
|
||||
import "package:google_fonts/google_fonts.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart" hide Row, Expanded;
|
||||
|
||||
import "package:provider/provider.dart";
|
||||
import "../../providers/chat_provider.dart";
|
||||
import "../../providers/cost_provider.dart";
|
||||
import "../../providers/settings_provider.dart";
|
||||
import "package:provider/provider.dart";
|
||||
import "ana_text.dart";
|
||||
|
||||
|
||||
|
||||
@@ -27,7 +29,7 @@ class FooterBar extends StatelessWidget {
|
||||
final theme = Theme.of(context);
|
||||
final mutedFg = theme.colorScheme.mutedForeground;
|
||||
final borderColor = theme.colorScheme.border;
|
||||
final bg = theme.colorScheme.muted.scaleAlpha(0.3);
|
||||
final bg = theme.colorScheme.background;
|
||||
|
||||
final costProvider = context.watch<CostProvider>();
|
||||
final settingsProvider = context.watch<SettingsProvider>();
|
||||
@@ -39,10 +41,11 @@ class FooterBar extends StatelessWidget {
|
||||
final inputToks = costProvider.getTotalInputTokens();
|
||||
final outputToks = costProvider.getTotalOutputTokens();
|
||||
final isLoading = chatProvider.isLoading;
|
||||
final isCompacting = chatProvider.isCompacting;
|
||||
final contextTokens = chatProvider.contextTokens;
|
||||
final warningState = chatProvider.tokenWarningState;
|
||||
|
||||
final textStyle = TextStyle(
|
||||
fontFamily: "monospace",
|
||||
final textStyle = GoogleFonts.ibmPlexMono(
|
||||
fontSize: 11,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -55,61 +58,103 @@ class FooterBar extends StatelessWidget {
|
||||
);
|
||||
|
||||
Widget copyrightBlock() {
|
||||
return Text(
|
||||
return AnaText(
|
||||
"© 2026 IMBENJI.NET LTD - The Agency",
|
||||
style: textStyle,
|
||||
);
|
||||
}
|
||||
|
||||
Widget statusBlock() {
|
||||
final label = isCompacting
|
||||
? "compacting..."
|
||||
: isLoading
|
||||
? "running..."
|
||||
: "idle";
|
||||
|
||||
final labelColor = isCompacting
|
||||
? theme.colorScheme.primary.withAlpha(180)
|
||||
: isLoading
|
||||
? theme.colorScheme.primary
|
||||
: mutedFg;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
Text(
|
||||
isLoading ? "running..." : "idle",
|
||||
style: textStyle.copyWith(
|
||||
color: isLoading
|
||||
? theme.colorScheme.primary
|
||||
: mutedFg,
|
||||
),
|
||||
),
|
||||
AnaText(label, style: textStyle.copyWith(color: labelColor)),
|
||||
|
||||
divider(),
|
||||
|
||||
Text(model.split("/").last, style: textStyle),
|
||||
AnaText(model.split("/").last, style: textStyle),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Color? _contextWarningColor() {
|
||||
if (warningState == null) return null;
|
||||
if (warningState.isAboveErrorThreshold) return const Color(0xFFEF4444); // red
|
||||
if (warningState.isAboveWarningThreshold) return const Color(0xFFF59E0B); // amber
|
||||
return null;
|
||||
}
|
||||
|
||||
Widget statsBlock() {
|
||||
final warnColor = _contextWarningColor();
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
// compact button — shown when we're near the threshold
|
||||
if (warningState != null && warningState.isAboveWarningThreshold && !isLoading && !isCompacting) ...[
|
||||
GestureDetector(
|
||||
onTap: () => chatProvider.runCompact(),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: AnaText(
|
||||
"compact",
|
||||
style: textStyle.copyWith(
|
||||
color: warnColor ?? mutedFg,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: warnColor ?? mutedFg,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
divider(),
|
||||
],
|
||||
|
||||
if (contextTokens > 0) ...[
|
||||
Text(_fmtTokens(contextTokens), style: textStyle),
|
||||
Text(" tokens", style: textStyle),
|
||||
Tooltip(
|
||||
tooltip: (_) => TooltipContainer(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: AnaText(
|
||||
warningState != null
|
||||
? "${warningState.percentLeft}% context remaining"
|
||||
: _fmtTokens(contextTokens),
|
||||
style: GoogleFonts.ibmPlexMono(fontSize: 11, height: 1.2),
|
||||
),
|
||||
),
|
||||
child: AnaText(
|
||||
_fmtTokens(contextTokens),
|
||||
style: textStyle.copyWith(color: warnColor ?? mutedFg),
|
||||
),
|
||||
),
|
||||
AnaText(" tokens", style: textStyle.copyWith(color: warnColor ?? mutedFg)),
|
||||
divider(),
|
||||
],
|
||||
|
||||
Tooltip(
|
||||
tooltip: (_) => TooltipContainer(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: Text(
|
||||
child: AnaText(
|
||||
"In: $inputToks\nOut: $outputToks",
|
||||
style: const TextStyle(
|
||||
fontFamily: "monospace",
|
||||
fontSize: 11,
|
||||
height: 1.2,
|
||||
),
|
||||
style: GoogleFonts.ibmPlexMono(fontSize: 11, height: 1.2),
|
||||
),
|
||||
),
|
||||
child: Text(cost, style: textStyle),
|
||||
child: AnaText(cost, style: textStyle),
|
||||
),
|
||||
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,79 @@
|
||||
import "dart:io";
|
||||
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
|
||||
class AppHeader extends StatelessWidget {
|
||||
const AppHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"THE AGENCY",
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.background,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: theme.colorScheme.border, width: 1),
|
||||
),
|
||||
Text(
|
||||
"by IMBENJI.NET LTD",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 8, bottom: 8),
|
||||
child: _Logo(),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
SizedBox(width: Platform.isMacOS ? 64 : 12),
|
||||
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _Logo extends StatelessWidget {
|
||||
const _Logo();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = Theme.of(context).colorScheme.mutedForeground;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
Text(
|
||||
"THE AGENCY",
|
||||
style: TextStyle(
|
||||
fontFamily: "monospace",
|
||||
fontSize: 13,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
|
||||
Text(
|
||||
"by IMBENJI.NET LTD",
|
||||
style: TextStyle(
|
||||
fontFamily: "monospace",
|
||||
fontSize: 10,
|
||||
height: 1.4,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: muted,
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ class Sidebar extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: _projectsSection(context, projectsProvider, sessionProvider, coordinator),
|
||||
child: _projectsSection(context, projectsProvider, sessionProvider, coordinator, chatProvider),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -109,7 +109,7 @@ class Sidebar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _projectsSection(BuildContext context, ProjectsProvider projectsProvider, SessionProvider sessionProvider, HomeCoordinator coordinator) {
|
||||
Widget _projectsSection(BuildContext context, ProjectsProvider projectsProvider, SessionProvider sessionProvider, HomeCoordinator coordinator, ChatProvider chatProvider) {
|
||||
if (projectsProvider.projects.isEmpty) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
@@ -131,6 +131,17 @@ class Sidebar extends StatelessWidget {
|
||||
..sort((a, b) => b.updated.compareTo(a.updated));
|
||||
});
|
||||
|
||||
final projects = List.of(projectsProvider.projects)
|
||||
..sort((a, b) {
|
||||
final aLatest = sorted[a.workingDirectory]?.firstOrNull?.updated;
|
||||
final bLatest = sorted[b.workingDirectory]?.firstOrNull?.updated;
|
||||
|
||||
if (aLatest == null && bLatest == null) return 0;
|
||||
if (aLatest == null) return 1;
|
||||
if (bLatest == null) return -1;
|
||||
return bLatest.compareTo(aLatest);
|
||||
});
|
||||
|
||||
return ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
@@ -142,7 +153,7 @@ class Sidebar extends StatelessWidget {
|
||||
|
||||
Gap(8),
|
||||
|
||||
for (final project in projectsProvider.projects) ...[
|
||||
for (final project in projects) ...[
|
||||
|
||||
ProjectSection(
|
||||
projectLabel: project.name,
|
||||
@@ -158,6 +169,7 @@ class Sidebar extends StatelessWidget {
|
||||
label: session.name,
|
||||
lastMessage: session.updated,
|
||||
selected: sessionProvider.currentSessionId == session.id,
|
||||
isRunning: chatProvider.isSessionRunning(session.id),
|
||||
onPressed: () => coordinator.openSession(session),
|
||||
onDelete: () => coordinator.deleteSession(session),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "../../../src/session/session_types.dart";
|
||||
import "../../providers/chat_provider.dart";
|
||||
import "../../providers/home_coordinator.dart";
|
||||
import "../../providers/projects_provider.dart";
|
||||
import "../../providers/session_provider.dart";
|
||||
import "../../utils/format_relative_time.dart";
|
||||
import "app_logo.dart";
|
||||
import "../common/button.dart";
|
||||
|
||||
class SidebarV2 extends StatelessWidget {
|
||||
const SidebarV2({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
width: 300,
|
||||
color: theme.colorScheme.input.scaleAlpha(0.3),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 28),
|
||||
child: AppLogo(),
|
||||
),
|
||||
|
||||
// Divider(color: theme.colorScheme.border, height: 1),
|
||||
_ActionsSection(),
|
||||
|
||||
Divider(color: theme.colorScheme.border, height: 1),
|
||||
|
||||
Expanded(child: _ProjectsSection()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionsSection extends StatelessWidget {
|
||||
const _ActionsSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final coordinator = context.read<HomeCoordinator>();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// _SectionHeader(title: "Actions"),
|
||||
Divider(color: theme.colorScheme.border, height: 1),
|
||||
|
||||
_PanelItem(
|
||||
icon: LucideIcons.squarePen,
|
||||
label: "New Chat",
|
||||
onTap: coordinator.createNewChat,
|
||||
),
|
||||
|
||||
Divider(color: theme.colorScheme.border, height: 1),
|
||||
|
||||
_PanelItem(
|
||||
icon: LucideIcons.folderPlus,
|
||||
label: "New Project",
|
||||
onTap: coordinator.pickProjectDirectory,
|
||||
),
|
||||
|
||||
Divider(color: theme.colorScheme.border, height: 1),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProjectsSection extends StatelessWidget {
|
||||
const _ProjectsSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final projectsProvider = context.watch<ProjectsProvider>();
|
||||
final sessionProvider = context.watch<SessionProvider>();
|
||||
final chatProvider = context.watch<ChatProvider>();
|
||||
final coordinator = context.read<HomeCoordinator>();
|
||||
|
||||
if (projectsProvider.projects.isEmpty) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_SectionHeader(title: "Projects"),
|
||||
|
||||
Divider(color: theme.colorScheme.border, height: 1),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Text(
|
||||
"No projects yet",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// group sessions by working directory, sorted newest first
|
||||
final sessionsByDir = <String, List<SessionSummary>>{};
|
||||
for (final s in sessionProvider.sessions) {
|
||||
final dir = s.workingDirectory ?? "";
|
||||
sessionsByDir.putIfAbsent(dir, () => []).add(s);
|
||||
}
|
||||
sessionsByDir.forEach((_, list) {
|
||||
list.sort((a, b) => b.updated.compareTo(a.updated));
|
||||
});
|
||||
|
||||
final projects = List.of(projectsProvider.projects)
|
||||
..sort((a, b) {
|
||||
final aLatest = sessionsByDir[a.workingDirectory]?.firstOrNull?.updated;
|
||||
final bLatest = sessionsByDir[b.workingDirectory]?.firstOrNull?.updated;
|
||||
|
||||
if (aLatest == null && bLatest == null) return 0;
|
||||
if (aLatest == null) return 1;
|
||||
if (bLatest == null) return -1;
|
||||
return bLatest.compareTo(aLatest);
|
||||
});
|
||||
|
||||
return ListView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
for (final project in projects) ...[
|
||||
_CollapsibleProjectSection(
|
||||
projectName: project.name,
|
||||
sessions: sessionsByDir[project.workingDirectory] ?? [],
|
||||
currentSessionId: sessionProvider.currentSessionId,
|
||||
isSessionRunning: chatProvider.isSessionRunning,
|
||||
sessionNeedsAttention: chatProvider.sessionNeedsAttention,
|
||||
sessionHasUnreadResult: chatProvider.sessionHasUnreadResult,
|
||||
onOpenSession: coordinator.openSession,
|
||||
onDeleteSession: coordinator.deleteSession,
|
||||
),
|
||||
|
||||
Divider(color: theme.colorScheme.border, height: 1),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CollapsibleProjectSection extends StatefulWidget {
|
||||
final String projectName;
|
||||
final List<SessionSummary> sessions;
|
||||
final String? currentSessionId;
|
||||
final bool Function(String sessionId) isSessionRunning;
|
||||
final bool Function(String sessionId) sessionNeedsAttention;
|
||||
final bool Function(String sessionId) sessionHasUnreadResult;
|
||||
final void Function(SessionSummary) onOpenSession;
|
||||
final void Function(SessionSummary) onDeleteSession;
|
||||
|
||||
const _CollapsibleProjectSection({
|
||||
required this.projectName,
|
||||
required this.sessions,
|
||||
required this.currentSessionId,
|
||||
required this.isSessionRunning,
|
||||
required this.sessionNeedsAttention,
|
||||
required this.sessionHasUnreadResult,
|
||||
required this.onOpenSession,
|
||||
required this.onDeleteSession,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_CollapsibleProjectSection> createState() =>
|
||||
_CollapsibleProjectSectionState();
|
||||
}
|
||||
|
||||
class _CollapsibleProjectSectionState
|
||||
extends State<_CollapsibleProjectSection> {
|
||||
bool _expanded = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: ColoredBox(
|
||||
color: theme.colorScheme.secondary,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
AnimatedRotation(
|
||||
turns: _expanded ? 0.0 : -0.25,
|
||||
duration: const Duration(milliseconds: 120),
|
||||
child: Icon(
|
||||
LucideIcons.chevronDown,
|
||||
size: 12,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 4),
|
||||
|
||||
Text(
|
||||
widget.projectName,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.foreground,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Divider(color: theme.colorScheme.border, height: 1),
|
||||
|
||||
if (_expanded) ...[
|
||||
if (widget.sessions.isEmpty)
|
||||
_PanelItem(
|
||||
icon: LucideIcons.frown,
|
||||
label: "No sessions yet",
|
||||
onTap: null,
|
||||
)
|
||||
else
|
||||
for (int i = 0; i < widget.sessions.length; i++) ...[
|
||||
_ThreadItem(
|
||||
session: widget.sessions[i],
|
||||
selected: widget.currentSessionId == widget.sessions[i].id,
|
||||
isRunning: widget.isSessionRunning(widget.sessions[i].id),
|
||||
needsAttention: widget.sessionNeedsAttention(widget.sessions[i].id),
|
||||
hasUnreadResult: widget.sessionHasUnreadResult(widget.sessions[i].id),
|
||||
onTap: () => widget.onOpenSession(widget.sessions[i]),
|
||||
onDelete: () => widget.onDeleteSession(widget.sessions[i]),
|
||||
),
|
||||
|
||||
if (i < widget.sessions.length - 1)
|
||||
Divider(color: theme.colorScheme.border, height: 1),
|
||||
],
|
||||
] else ...[
|
||||
Divider(color: theme.colorScheme.background,)
|
||||
]
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
const _SectionHeader({required this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return ColoredBox(
|
||||
color: theme.colorScheme.secondary,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.foreground,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PanelItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _PanelItem({required this.icon, required this.label, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final muted = onTap == null;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(1),
|
||||
child: AgcGhostButton(
|
||||
onPressed: onTap,
|
||||
borderRadius: BorderRadius.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: muted
|
||||
? theme.colorScheme.mutedForeground
|
||||
: theme.colorScheme.foreground,
|
||||
).iconSmall,
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: muted
|
||||
? theme.colorScheme.mutedForeground
|
||||
: theme.colorScheme.foreground,
|
||||
),
|
||||
).small,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThreadItem extends StatelessWidget {
|
||||
final SessionSummary session;
|
||||
final bool selected;
|
||||
final bool isRunning;
|
||||
final bool needsAttention;
|
||||
final bool hasUnreadResult;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
const _ThreadItem({
|
||||
required this.session,
|
||||
required this.selected,
|
||||
this.isRunning = false,
|
||||
this.needsAttention = false,
|
||||
this.hasUnreadResult = false,
|
||||
required this.onTap,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
const amber = Color(0xFFF59E0B);
|
||||
const green = Color(0xFF22C55E);
|
||||
|
||||
Color? glowColor;
|
||||
if (needsAttention) {
|
||||
glowColor = amber;
|
||||
} else if (hasUnreadResult) {
|
||||
glowColor = green;
|
||||
}
|
||||
|
||||
Widget trailingWidget;
|
||||
if (needsAttention) {
|
||||
trailingWidget = Icon(LucideIcons.triangleAlert, size: 13, color: amber);
|
||||
} else if (isRunning) {
|
||||
trailingWidget = SizedBox(
|
||||
width: 12, height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
);
|
||||
} else if (hasUnreadResult) {
|
||||
trailingWidget = Icon(LucideIcons.circleCheck, size: 13, color: green);
|
||||
} else {
|
||||
trailingWidget = Text(
|
||||
formatRelativeTime(session.updated),
|
||||
style: TextStyle(color: theme.colorScheme.mutedForeground),
|
||||
).muted.xSmall.light;
|
||||
}
|
||||
|
||||
return ContextMenu(
|
||||
items: [
|
||||
MenuButton(
|
||||
leading: const Icon(LucideIcons.trash2).iconSmall,
|
||||
onPressed: (_) => onDelete(),
|
||||
child: const Text("Delete"),
|
||||
),
|
||||
],
|
||||
child: Container(
|
||||
margin: EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(
|
||||
color: glowColor != null
|
||||
? glowColor.withAlpha(selected ? 55 : 30)
|
||||
: selected ? theme.colorScheme.accent : Colors.transparent,
|
||||
border: glowColor != null
|
||||
? Border(left: BorderSide(color: glowColor, width: 2))
|
||||
: null,
|
||||
),
|
||||
child: AgcGhostButton(
|
||||
onPressed: onTap,
|
||||
borderRadius: BorderRadius.zero,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: glowColor != null ? 22 : 24,
|
||||
right: 12,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
session.name,
|
||||
style: TextStyle(
|
||||
color: selected
|
||||
? theme.colorScheme.accentForeground
|
||||
: theme.colorScheme.foreground,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
).small,
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
trailingWidget,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ class ThreadButton extends StatelessWidget {
|
||||
final DateTime? lastMessage;
|
||||
final bool selected;
|
||||
final bool muted;
|
||||
final bool isRunning;
|
||||
|
||||
ThreadButton({
|
||||
required this.label,
|
||||
@@ -19,6 +20,7 @@ class ThreadButton extends StatelessWidget {
|
||||
this.lastMessage,
|
||||
this.selected = false,
|
||||
this.muted = false,
|
||||
this.isRunning = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -50,10 +52,18 @@ class ThreadButton extends StatelessWidget {
|
||||
).iconSmall,
|
||||
|
||||
trailingGap: 32,
|
||||
trailing: lastMessage != null ?
|
||||
Text(
|
||||
formatRelativeTime(lastMessage!)
|
||||
).muted.small.light : null,
|
||||
trailing: isRunning
|
||||
? SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
)
|
||||
: lastMessage != null
|
||||
? Text(formatRelativeTime(lastMessage!)).muted.small.light
|
||||
: null,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
|
||||
Reference in New Issue
Block a user