Add initial project files and configurations for clawd_code
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class GarageHeader extends StatelessWidget {
|
||||
const GarageHeader({super.key});
|
||||
class AppHeader extends StatelessWidget {
|
||||
const AppHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
+164
-20
@@ -1,5 +1,5 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "../providers/chat_provider.dart";
|
||||
import "message_bubble.dart";
|
||||
@@ -13,49 +13,193 @@ class ChatView extends StatefulWidget {
|
||||
|
||||
class _ChatViewState extends State<ChatView> {
|
||||
late ScrollController _scrollController;
|
||||
List<String> _previousMessageContents = [];
|
||||
bool _isUserScrolling = false;
|
||||
DateTime? _lastScrollTime;
|
||||
bool _showJumpToBottom = false;
|
||||
bool _hasNewMessagesWhileScrolledAway = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController = ScrollController();
|
||||
_scrollController.addListener(_handleScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_handleScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
void _handleScroll() {
|
||||
_lastScrollTime = DateTime.now();
|
||||
_isUserScrolling = true;
|
||||
|
||||
// Update whether to show jump-to-bottom button
|
||||
if (_scrollController.hasClients) {
|
||||
final position = _scrollController.position;
|
||||
final isFarFromBottom = position.pixels < position.maxScrollExtent - 200;
|
||||
if (isFarFromBottom != _showJumpToBottom) {
|
||||
setState(() {
|
||||
_showJumpToBottom = isFarFromBottom;
|
||||
});
|
||||
}
|
||||
|
||||
// If user scrolls to bottom manually, clear the new messages flag
|
||||
if (!isFarFromBottom) {
|
||||
setState(() {
|
||||
_hasNewMessagesWhileScrolledAway = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if scrolling has stopped (no scroll events for 150ms)
|
||||
Future.delayed(const Duration(milliseconds: 150), () {
|
||||
if (_lastScrollTime != null &&
|
||||
DateTime.now().difference(_lastScrollTime!) >= const Duration(milliseconds: 150)) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isUserScrolling = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool _isNearBottom() {
|
||||
if (!_scrollController.hasClients) return false;
|
||||
|
||||
final position = _scrollController.position;
|
||||
// Consider user to be "near bottom" if they're within 150 pixels of the bottom
|
||||
// Add a small buffer so we don't trigger on exact bottom
|
||||
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, _) {
|
||||
// scroll to bottom when new messages arrive
|
||||
if (chatProvider.messages.isNotEmpty) {
|
||||
_scrollToBottom();
|
||||
// Get current messages
|
||||
final currentMessages = chatProvider.messages;
|
||||
|
||||
// Check if messages have actually changed (not just re-renders)
|
||||
bool messagesChanged = false;
|
||||
|
||||
if (currentMessages.length != _previousMessageContents.length) {
|
||||
messagesChanged = true;
|
||||
} else {
|
||||
for (int i = 0; i < currentMessages.length; i++) {
|
||||
if (i >= _previousMessageContents.length ||
|
||||
currentMessages[i].content != _previousMessageContents[i]) {
|
||||
messagesChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messagesChanged && currentMessages.isNotEmpty) {
|
||||
// Check if we're near the bottom
|
||||
final nearBottom = _isNearBottom();
|
||||
|
||||
if (nearBottom && !_isUserScrolling) {
|
||||
// Auto-scroll to bottom if user is near bottom and not scrolling
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
_hasNewMessagesWhileScrolledAway = false;
|
||||
} else if (!nearBottom) {
|
||||
// User is scrolled away from bottom when new messages arrive
|
||||
_hasNewMessagesWhileScrolledAway = true;
|
||||
}
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: chatProvider.messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = chatProvider.messages[index];
|
||||
return MessageBubble(message: message);
|
||||
},
|
||||
// Update previous message state for next build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_previousMessageContents = currentMessages.map((m) => m.content).toList();
|
||||
});
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: currentMessages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = currentMessages[index];
|
||||
return Padding(
|
||||
padding: EdgeInsetsGeometry.only(
|
||||
top: index != 0 ? 12 : 0
|
||||
),
|
||||
child: MessageBubble(message: message)
|
||||
);
|
||||
},
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "../providers/cost_provider.dart";
|
||||
|
||||
class CostBadge extends StatelessWidget {
|
||||
const CostBadge();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<CostProvider>(
|
||||
builder: (context, costProvider, _) {
|
||||
final costStr = costProvider.getFormattedTotalCost();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF1F5F9),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
costStr,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF475569),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "../providers/chat_provider.dart";
|
||||
|
||||
class InputBar extends StatefulWidget {
|
||||
const InputBar({super.key});
|
||||
|
||||
@override
|
||||
State<InputBar> createState() => _InputBarState();
|
||||
}
|
||||
|
||||
class _InputBarState extends State<InputBar> {
|
||||
late TextEditingController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _send(ChatProvider provider) {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
provider.sendMessage(text);
|
||||
_controller.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<ChatProvider>(
|
||||
builder: (context, chatProvider, _) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(color: Color(0xFFE2E8F0)),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
placeholder: const Text("Type a message..."),
|
||||
enabled: !chatProvider.isLoading,
|
||||
onSubmitted: chatProvider.isLoading ? null : (_) => _send(chatProvider),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 10),
|
||||
|
||||
chatProvider.isLoading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(value: null),
|
||||
),
|
||||
)
|
||||
: PrimaryButton(
|
||||
onPressed: () => _send(chatProvider),
|
||||
child: const Text("Send"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import "package:flutter/material.dart" as material hide Card;
|
||||
import "package:flutter/src/material/theme_data.dart";
|
||||
import "package:flutter_markdown/flutter_markdown.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
@@ -14,47 +14,95 @@ class MessageBubble extends StatelessWidget {
|
||||
final isUser = message.role == "user";
|
||||
final isTool = message.role == "tool";
|
||||
final isAssistant = message.role == "assistant";
|
||||
final accentColor = isTool
|
||||
? const Color(0xFF64748B)
|
||||
: const Color(0xFF94A3B8);
|
||||
|
||||
final theme = Theme.of(context);
|
||||
|
||||
|
||||
if (isUser) {
|
||||
return Row(
|
||||
children: [
|
||||
Spacer(),
|
||||
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) {
|
||||
return MarkdownBody(
|
||||
data: message.content,
|
||||
selectable: true,
|
||||
shrinkWrap: true,
|
||||
styleSheet: _toolMarkdownStyleSheet(context),
|
||||
);
|
||||
} else if (isTool) {
|
||||
|
||||
final lines = message.content.split("\n");
|
||||
final title = lines.first.trim();
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
|
||||
Container(
|
||||
height: 10,
|
||||
width: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle
|
||||
),
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
Text(
|
||||
title,
|
||||
style: theme.typography.p.copyWith(
|
||||
fontSize: 13
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Align(
|
||||
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: material.MediaQuery.of(context).size.width * 0.7,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: material.Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
message.role,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: accentColor,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
message.role,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (isAssistant || isTool)
|
||||
MarkdownBody(
|
||||
data: isTool
|
||||
? _buildToolMarkdown(message.content)
|
||||
: message.content,
|
||||
selectable: true,
|
||||
shrinkWrap: true,
|
||||
styleSheet: isTool
|
||||
? _toolMarkdownStyleSheet(context)
|
||||
: null,
|
||||
)
|
||||
else
|
||||
Text(message.content),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (isAssistant || isTool)
|
||||
MarkdownBody(
|
||||
data: isTool
|
||||
? _buildToolMarkdown(message.content)
|
||||
: message.content,
|
||||
selectable: true,
|
||||
shrinkWrap: true,
|
||||
styleSheet: isTool
|
||||
? _toolMarkdownStyleSheet(context)
|
||||
: null,
|
||||
)
|
||||
else
|
||||
Text(message.content),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -78,16 +126,9 @@ class MessageBubble extends StatelessWidget {
|
||||
|
||||
MarkdownStyleSheet _toolMarkdownStyleSheet(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return MarkdownStyleSheet.fromTheme(material.Theme.of(context)).copyWith(
|
||||
p: theme.typography.base.copyWith(height: 1.35),
|
||||
codeblockDecoration: BoxDecoration(
|
||||
color: theme.colorScheme.muted.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
codeblockPadding: const EdgeInsets.all(12),
|
||||
code: theme.typography.base.copyWith(
|
||||
fontFamily: "monospace",
|
||||
height: 1.35,
|
||||
return MarkdownStyleSheet(
|
||||
p: theme.typography.p.copyWith(
|
||||
fontSize: 13
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "../providers/chat_provider.dart";
|
||||
import "../providers/session_provider.dart";
|
||||
|
||||
class Sidebar extends StatelessWidget {
|
||||
const Sidebar();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<SessionProvider>(
|
||||
builder: (context, sessionProvider, _) {
|
||||
return Column(
|
||||
children: [
|
||||
// sessions list
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: sessionProvider.sessions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final session = sessionProvider.sessions[index];
|
||||
final isSelected =
|
||||
sessionProvider.currentSessionId == session.id;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
await sessionProvider.loadSession(session.id);
|
||||
if (context.mounted) {
|
||||
final chatProvider =
|
||||
Provider.of<ChatProvider>(context, listen: false);
|
||||
chatProvider.setConversation(
|
||||
sessionProvider.getConversationHistory(),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? const Color(0xFFEFF6FF)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
session.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${session.messageCount} msgs",
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF94A3B8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// new session button
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: PrimaryButton(
|
||||
onPressed: () async {
|
||||
await sessionProvider.createNewSession();
|
||||
if (context.mounted) {
|
||||
final chatProvider =
|
||||
Provider.of<ChatProvider>(context, listen: false);
|
||||
chatProvider.setConversation(
|
||||
sessionProvider.getConversationHistory(),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text("+ New"),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user