From fa4415553d3d4567304b4feb3322e4eefa6ce702 Mon Sep 17 00:00:00 2001 From: ImBenji Date: Sat, 4 Apr 2026 05:46:34 +0100 Subject: [PATCH] Add initial project files and configurations for clawd_code --- SCROLLING_FIX_SUMMARY.md | 59 ++++ lib/src/api/openrouter_client.dart | 233 +++++++++++++ lib/src/api/response_parser.dart | 19 +- lib/src/chat/tool_loop_service.dart | 15 +- lib/src/session/conversation_history.dart | 15 + lib/ui/constants.dart | 5 + .../home_screen/page.dart} | 314 ++++++++---------- lib/ui/providers/chat_provider.dart | 27 +- lib/ui/widgets/app_header.dart | 4 +- lib/ui/widgets/chat_view.dart | 184 ++++++++-- lib/ui/widgets/cost_badge.dart | 33 -- lib/ui/widgets/input_bar.dart | 82 ----- lib/ui/widgets/message_bubble.dart | 135 +++++--- lib/ui/widgets/sidebar.dart | 97 ------ 14 files changed, 763 insertions(+), 459 deletions(-) create mode 100644 SCROLLING_FIX_SUMMARY.md rename lib/ui/{screens/new_home_screen.dart => pages/home_screen/page.dart} (69%) delete mode 100644 lib/ui/widgets/cost_badge.dart delete mode 100644 lib/ui/widgets/input_bar.dart delete mode 100644 lib/ui/widgets/sidebar.dart diff --git a/SCROLLING_FIX_SUMMARY.md b/SCROLLING_FIX_SUMMARY.md new file mode 100644 index 0000000..e21d644 --- /dev/null +++ b/SCROLLING_FIX_SUMMARY.md @@ -0,0 +1,59 @@ +# Chat View Scrolling Fix Summary + +## Problem Analysis +The chat view had inconsistent scroll thumb behavior where it would "jump around" during use. This was caused by: + +1. **Aggressive auto-scrolling**: The `_scrollToBottom()` function was called on every build when messages were present +2. **Interference with user scrolling**: During message streaming, the chat provider calls `notifyListeners()` frequently (on each text delta), triggering rebuilds and auto-scrolling +3. **No user scroll detection**: The system couldn't distinguish between user-initiated scrolling and auto-scrolling + +## Solution Implemented + +### 1. Smart Auto-Scrolling Logic +- Only auto-scrolls when new messages arrive AND user is near the bottom (within 150px) +- Uses `_isNearBottom()` to check scroll position +- Tracks actual message content changes, not just rebuilds + +### 2. User Scroll Detection +- Uses `ScrollController` listener to detect when user is scrolling +- Implements 150ms debouncing to detect when scrolling stops +- Sets `_isUserScrolling` flag to prevent auto-scrolling while user is interacting + +### 3. Jump-to-Bottom Button +- When user scrolls away from bottom (>200px) and new messages arrive, shows a "New messages" button +- Button appears in bottom-right corner with subtle animation +- Clicking it smoothly scrolls to bottom and hides the button +- Button only shows when there are actually new messages while user is scrolled away + +### 4. Message Change Tracking +- Tracks previous message contents to detect actual changes (not just re-renders) +- Prevents unnecessary auto-scrolling on provider updates that don't change message content + +## Technical Details + +### Key Variables +- `_isUserScrolling`: Tracks if user is actively scrolling +- `_showJumpToBottom`: Whether to show the jump-to-bottom button +- `_hasNewMessagesWhileScrolledAway`: Whether new messages arrived while user was scrolled away +- `_previousMessageContents`: List of previous message contents for change detection + +### Scroll Thresholds +- **Near bottom**: Within 150px of bottom (triggers auto-scroll) +- **Far from bottom**: More than 200px from bottom (shows jump button) +- **Debounce timeout**: 150ms (detects scroll stop) + +## Benefits +1. **Smooth scrolling**: No more jumpy scroll thumb during streaming +2. **User control**: Users can scroll up to read previous messages without being forced back to bottom +3. **Clear UX**: Jump-to-bottom button provides clear indication of new messages +4. **Performance**: Reduces unnecessary scroll animations + +## Testing +To test the fix: +1. Send multiple messages to create a scrollable chat +2. Scroll up to read previous messages during streaming +3. Observe that auto-scroll doesn't interfere +4. See the jump-to-bottom button appear when new messages arrive +5. Click the button to smoothly return to bottom + +The fix maintains the original behavior for users who are at/near the bottom while preventing the disruptive scrolling behavior for users actively reading previous messages. \ No newline at end of file diff --git a/lib/src/api/openrouter_client.dart b/lib/src/api/openrouter_client.dart index 6a89885..2cd02ee 100644 --- a/lib/src/api/openrouter_client.dart +++ b/lib/src/api/openrouter_client.dart @@ -103,6 +103,218 @@ class OpenRouterClient { return ResponseParser.parseOpenRouterResponse(response); } + Future createStreamingMessage({ + required String model, + required int maxTokens, + required List> messages, + String? system, + double? temperature, + List>? tools, + String? toolChoice, + void Function(String delta)? onTextDelta, + }) async { + final requestBody = { + "model": model, + "max_tokens": maxTokens, + "messages": messages, + "stream": true, + "stream_options": {"include_usage": true}, + }; + + if (system != null) { + if (messages.isEmpty || messages.first["role"] != "system") { + requestBody["messages"] = [ + {"role": "system", "content": system}, + ...messages, + ]; + } + } + + if (temperature != null) { + requestBody["temperature"] = temperature; + } + + if (tools != null && tools.isNotEmpty) { + requestBody["tools"] = tools; + if (toolChoice != null) { + requestBody["tool_choice"] = toolChoice; + } + } + + final url = Uri.parse("$_baseUrl/chat/completions"); + final headers = _buildHeaders(); + + final textBuffer = StringBuffer(); + final toolCalls = {}; + String responseId = ""; + String responseModel = model; + String? finishReason; + Map? usage; + + try { + if (_requestCancelled) { + throw const RequestCancelledException(); + } + + final request = await _httpClient.openUrl("POST", url); + headers.forEach((key, value) { + request.headers.set(key, value); + }); + request.headers.contentType = ContentType.json; + request.write(jsonEncode(requestBody)); + + final response = await request.close(); + if (response.statusCode >= 400) { + final responseBody = await response.transform(utf8.decoder).join(); + print( + "OpenRouter API error ${response.statusCode} for /chat/completions: $responseBody", + ); + _handleErrorResponse(response.statusCode, responseBody); + } + + final responseStream = response + .transform(utf8.decoder) + .transform(const LineSplitter()); + + await for (final line in responseStream) { + if (_requestCancelled) { + throw const RequestCancelledException(); + } + + final event = StreamingResponseParser.parseStreamLine(line); + if (StreamingResponseParser.isDone(event)) { + break; + } + if (event == null) { + continue; + } + + final id = event["id"]; + if (id is String && id.isNotEmpty) { + responseId = id; + } + + final streamedModel = event["model"]; + if (streamedModel is String && streamedModel.isNotEmpty) { + responseModel = streamedModel; + } + + final rawUsage = event["usage"]; + if (rawUsage is Map) { + usage = rawUsage; + } + + final choices = event["choices"]; + if (choices is! List || choices.isEmpty) { + continue; + } + + final firstChoice = choices.first; + if (firstChoice is! Map) { + continue; + } + + final rawFinishReason = firstChoice["finish_reason"]; + if (rawFinishReason is String && rawFinishReason.isNotEmpty) { + finishReason = rawFinishReason == "tool_calls" + ? "tool_use" + : rawFinishReason; + } + + final delta = firstChoice["delta"]; + if (delta is! Map) { + continue; + } + + final content = delta["content"]; + if (content is String && content.isNotEmpty) { + textBuffer.write(content); + onTextDelta?.call(content); + } + + final toolCallDeltas = delta["tool_calls"]; + if (toolCallDeltas is! List) { + continue; + } + + for (final rawToolCall in toolCallDeltas) { + if (rawToolCall is! Map) { + continue; + } + + final index = (rawToolCall["index"] as num?)?.toInt() ?? 0; + final builder = toolCalls.putIfAbsent( + index, + () => _StreamingToolCallBuilder(), + ); + + final toolCallId = rawToolCall["id"]; + if (toolCallId is String && toolCallId.isNotEmpty) { + builder.id = toolCallId; + } + + final rawType = rawToolCall["type"]; + if (rawType is String && rawType.isNotEmpty) { + builder.type = rawType; + } + + final function = rawToolCall["function"]; + if (function is! Map) { + continue; + } + + final name = function["name"]; + if (name is String && name.isNotEmpty) { + builder.name = name; + } + + final arguments = function["arguments"]; + if (arguments is String && arguments.isNotEmpty) { + builder.arguments.write(arguments); + } + } + } + + final contentBlocks = >[]; + final text = textBuffer.toString(); + if (text.isNotEmpty) { + contentBlocks.add({"type": "text", "text": text}); + } + + final orderedToolCalls = toolCalls.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)); + for (final entry in orderedToolCalls) { + final builder = entry.value; + contentBlocks.add({ + "type": "tool_use", + "id": builder.id, + "name": builder.name, + "input": builder.parsedArguments, + }); + } + + return ApiMessage( + id: responseId, + type: "message", + role: "assistant", + content: contentBlocks, + model: responseModel, + stopReason: finishReason, + usage: usage, + inputTokens: (usage?["prompt_tokens"] as num?)?.toInt(), + outputTokens: (usage?["completion_tokens"] as num?)?.toInt(), + ); + } catch (e) { + if (_requestCancelled) { + throw const RequestCancelledException(); + } + if (_config.enableLogging) { + _log("[API STREAM ERROR] $e"); + } + rethrow; + } + } + // List available models Future>> listModels() async { final response = await _makeRequest(method: "GET", endpoint: "/models"); @@ -225,6 +437,27 @@ class OpenRouterClient { } } +class _StreamingToolCallBuilder { + String id = ""; + String type = "function"; + String name = ""; + final StringBuffer arguments = StringBuffer(); + + Map get parsedArguments { + final raw = arguments.toString(); + if (raw.isEmpty) { + return {}; + } + + try { + final decoded = jsonDecode(raw); + return decoded is Map ? decoded : {}; + } catch (_) { + return {}; + } + } +} + class RequestCancelledException implements Exception { const RequestCancelledException(); diff --git a/lib/src/api/response_parser.dart b/lib/src/api/response_parser.dart index 0bdd0da..91d4393 100644 --- a/lib/src/api/response_parser.dart +++ b/lib/src/api/response_parser.dart @@ -1,6 +1,8 @@ // Response parser for Anthropic Message API responses // Ported from old_repo/services/api/errors.ts and claude.ts +import "dart:convert"; + import "api_types.dart"; // Parse Message API response into ApiMessage model @@ -145,12 +147,15 @@ class ErrorParser { class StreamingResponseParser { // parse a streamed event from newline-delimited JSON static Map? parseStreamLine(String line) { - if (line.trim().isEmpty) return null; + final trimmed = line.trim(); + if (trimmed.isEmpty || trimmed.startsWith(":")) return null; try { // handle SSE format (data: {...}) final data = line.startsWith("data: ") ? line.substring(6) : line; - // simple JSON parsing - in production would use json.decode + if (data.trim() == "[DONE]") { + return {"type": "done"}; + } return _parseJson(data); } catch (_) { return null; @@ -158,9 +163,8 @@ class StreamingResponseParser { } static Map? _parseJson(String jsonStr) { - // stubbed - would use dart:convert.jsonDecode in real impl - // for now just return null to indicate parsing would happen - return null; + final decoded = jsonDecode(jsonStr); + return decoded is Map ? decoded : null; } // check if streamed event is a message delta (partial response) @@ -177,6 +181,11 @@ class StreamingResponseParser { return type == "message_stop"; } + static bool isDone(Map? event) { + if (event == null) return false; + return event["type"] == "done"; + } + // extract partial text from delta event static String? extractDeltaText(Map? event) { if (event == null) return null; diff --git a/lib/src/chat/tool_loop_service.dart b/lib/src/chat/tool_loop_service.dart index 2a276eb..2bff756 100644 --- a/lib/src/chat/tool_loop_service.dart +++ b/lib/src/chat/tool_loop_service.dart @@ -13,11 +13,13 @@ class ToolLoopResult { required this.apiMessages, required this.responseText, required this.response, + required this.finalResponseWasStreamed, }); final List> apiMessages; final String responseText; final ApiMessage response; + final bool finalResponseWasStreamed; } class ToolLoopException implements Exception { @@ -48,6 +50,8 @@ class ToolLoopService { String? workingDirectory, void Function(String toolName, Map input)? onToolCall, void Function(String toolName, String result)? onToolResult, + void Function(String delta)? onAssistantTextDelta, + void Function()? onAssistantMessageComplete, }) async { final updatedMessages = List>.from(apiMessages) ..add({"role": "user", "content": userText}); @@ -56,14 +60,22 @@ class ToolLoopService { try { while (true) { - lastResponse = await client.createMessage( + bool streamedTextThisIteration = false; + lastResponse = await client.createStreamingMessage( model: model, maxTokens: 4096, messages: updatedMessages, system: _buildSystemPrompt(workingDirectory), tools: _buildToolDefinitions(), toolChoice: "auto", + onTextDelta: (delta) { + streamedTextThisIteration = true; + onAssistantTextDelta?.call(delta); + }, ); + if (streamedTextThisIteration) { + onAssistantMessageComplete?.call(); + } updatedMessages.add(_assistantMessageForApi(lastResponse)); @@ -78,6 +90,7 @@ class ToolLoopService { ? _buildEmptyAssistantFallback(lastResponse) : responseText, response: lastResponse, + finalResponseWasStreamed: streamedTextThisIteration, ); } diff --git a/lib/src/session/conversation_history.dart b/lib/src/session/conversation_history.dart index ed12c94..4cda70e 100644 --- a/lib/src/session/conversation_history.dart +++ b/lib/src/session/conversation_history.dart @@ -30,6 +30,21 @@ class ConversationHistory { _session!.updated = DateTime.now().toUtc(); } + void appendToLastMessage(String text) { + if (_session == null || _session!.messages.isEmpty || text.isEmpty) { + return; + } + + final lastMessage = _session!.messages.last; + _session!.messages[_session!.messages.length - 1] = Message( + role: lastMessage.role, + content: "${lastMessage.content}$text", + timestamp: lastMessage.timestamp, + tokens: lastMessage.tokens, + ); + _session!.updated = DateTime.now().toUtc(); + } + void removeLastMessage() { if (_session == null || _session!.messages.isEmpty) { return; diff --git a/lib/ui/constants.dart b/lib/ui/constants.dart index 0d6731b..532e8bc 100644 --- a/lib/ui/constants.dart +++ b/lib/ui/constants.dart @@ -31,6 +31,11 @@ const List selectableAiModels = [ group: "Recommended", id: "deepseek/deepseek-v3.2", label: "DeepSeek v3.2", + ), + SelectableAiModel( + group: "Recommended", + id: "qwen/qwen3-coder-next", + label: "Qwen3 Coder Next", ) diff --git a/lib/ui/screens/new_home_screen.dart b/lib/ui/pages/home_screen/page.dart similarity index 69% rename from lib/ui/screens/new_home_screen.dart rename to lib/ui/pages/home_screen/page.dart index 3b44c1f..aa59b7b 100644 --- a/lib/ui/screens/new_home_screen.dart +++ b/lib/ui/pages/home_screen/page.dart @@ -2,17 +2,17 @@ import "package:file_picker/file_picker.dart"; import "package:provider/provider.dart"; import "package:shadcn_flutter/shadcn_flutter.dart"; -import "../../src/project_store.dart"; -import "../../src/session/session_types.dart"; -import "../constants.dart"; -import "../providers/chat_provider.dart"; -import "../providers/cost_provider.dart"; -import "../providers/projects_provider.dart"; -import "../providers/session_provider.dart"; -import "../providers/settings_provider.dart"; -import "../widgets/app_header.dart"; -import "../widgets/chat_view.dart"; -import "../widgets/settings_sheet.dart"; +import "../../../src/project_store.dart"; +import "../../../src/session/session_types.dart"; +import "../../constants.dart"; +import "../../providers/chat_provider.dart"; +import "../../providers/cost_provider.dart"; +import "../../providers/projects_provider.dart"; +import "../../providers/session_provider.dart"; +import "../../providers/settings_provider.dart"; +import "../../widgets/app_header.dart"; +import "../../widgets/chat_view.dart"; +import "../../widgets/settings_sheet.dart"; class NewHomeScreen extends StatefulWidget { const NewHomeScreen({super.key}); @@ -268,7 +268,7 @@ class _NewHomeScreenState extends State { const Gap(16), const Padding( padding: EdgeInsets.symmetric(horizontal: 16), - child: GarageHeader(), + child: AppHeader(), ), Padding( padding: const EdgeInsets.all(8), @@ -332,179 +332,159 @@ class _NewHomeScreenState extends State { ), const VerticalDivider(), Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 18), - child: Column( - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - selectedProject?.name ?? "Choose a project", - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.w700, - ), - ), - const Gap(6), - Text( - sessionProvider.currentSession?.name ?? - (selectedProject == null - ? "Use the file picker to choose a working directory." - : "Start a new chat in this working directory."), - ).textSmall.muted, - ], - ), - ), - const Gap(16), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - OutlinedContainer( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text("Working Directory").textSmall.muted, - const Gap(4), - SizedBox( - width: 280, - child: Text( - selectedWorkingDirectory ?? "Not selected", - textAlign: TextAlign.right, - overflow: TextOverflow.ellipsis, - ).textSmall, - ), - ], - ), - ), - const Gap(8), - Row( - mainAxisSize: MainAxisSize.min, + child: Column( + children: [ + + if (selectedProject != null && sessionProvider.currentSession != null)...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12 + ), + child: Row( + children: [ + + Icon( + LucideIcons.messageCircle + ).iconSmall, + + Gap(8), + + Transform.translate( + offset: Offset(0, -1), + child: Row( children: [ - OutlinedContainer( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - child: Text( - "Session cost ${costProvider.getFormattedTotalCost()}", - ).textSmall, - ), - const Gap(8), - IconButton.ghost( - onPressed: _openSettings, - icon: const Icon(LucideIcons.settings2), + Text( + selectedProject.name + ).textSmall, + + Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Icon( + LucideIcons.slash + ).iconX2Small, ), + + Text( + sessionProvider.currentSession!.name + ).textSmall ], ), - ], - ), - ], - ), - const Gap(20), - Expanded( - child: ClipRect( - child: OutlinedContainer( - child: chatProvider.messages.isEmpty - ? _EmptyChatState( - projectName: selectedProject?.name, - hasProject: selectedProject != null, - ) - : const ChatView(), - ), + ), + + + + ], ), ), - const Gap(16), - TextField( - controller: _messageController, - minLines: 3, - maxLines: 6, - enabled: !chatProvider.isLoading, - placeholder: Text( - selectedProject == null - ? "Choose a project to start chatting" - : "Ask a question or type a message", + Divider(), + ], + + + const Gap(18), + Expanded( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 600 ), - onSubmitted: chatProvider.isLoading - ? null - : (_) => _sendMessage(), - features: [ - InputFeature.below( - Row( - children: [ - IconButton.ghost( - onPressed: _pickProjectDirectory, - icon: const Icon(LucideIcons.folderSearch), - ), - const Spacer(), - Select( - itemBuilder: (context, item) { - return Text(_modelLabel(item)); - }, - popup: SelectPopup.builder( - searchPlaceholder: const Text("Search models"), - builder: (context, searchQuery) { - final filteredModels = searchQuery == null - ? _modelGroups.entries - : _filteredModels(searchQuery); - return SelectItemList( - children: [ - for (final entry in filteredModels) - SelectGroup( - headers: [ - SelectLabel(child: Text(entry.key)), - ], + child: Column( + children: [ + Expanded( + child: ClipRect( + child: chatProvider.messages.isEmpty + ? _EmptyChatState( + projectName: selectedProject?.name, + hasProject: selectedProject != null, + ) + : const ChatView(), + ), + ), + const Gap(16), + TextField( + controller: _messageController, + minLines: 3, + maxLines: 6, + enabled: !chatProvider.isLoading, + placeholder: Text( + selectedProject == null + ? "Choose a project to start chatting" + : "Ask a question or type a message", + ), + onSubmitted: chatProvider.isLoading + ? null + : (_) => _sendMessage(), + features: [ + InputFeature.below( + Row( + children: [ + IconButton.ghost( + onPressed: _pickProjectDirectory, + icon: const Icon(LucideIcons.folderSearch), + ), + const Spacer(), + Select( + itemBuilder: (context, item) { + return Text(_modelLabel(item)); + }, + popup: SelectPopup.builder( + searchPlaceholder: const Text("Search models"), + builder: (context, searchQuery) { + final filteredModels = searchQuery == null + ? _modelGroups.entries + : _filteredModels(searchQuery); + return SelectItemList( children: [ - for (final modelId in entry.value) - SelectItemButton( - value: modelId, - child: Text( - _modelLabel(modelId), - ), + for (final entry in filteredModels) + SelectGroup( + headers: [ + SelectLabel(child: Text(entry.key)), + ], + children: [ + for (final modelId in entry.value) + SelectItemButton( + value: modelId, + child: Text( + _modelLabel(modelId), + ), + ), + ], ), ], - ), - ], - ); - }, - ), - onChanged: (value) { - if (value != null) { - settingsProvider.updateModel(value); - } - }, - constraints: const BoxConstraints(minWidth: 220), - value: currentModel, - placeholder: const Text("Select a model"), - ), - const Gap(10), - Button.primary( - onPressed: chatProvider.isLoading - ? _stopMessage - : _sendMessage, - child: chatProvider.isLoading - ? Text( + ); + }, + ), + onChanged: (value) { + if (value != null) { + settingsProvider.updateModel(value); + } + }, + constraints: const BoxConstraints(minWidth: 220), + value: currentModel, + placeholder: const Text("Select a model"), + ), + const Gap(10), + Button.primary( + onPressed: chatProvider.isLoading + ? _stopMessage + : _sendMessage, + child: chatProvider.isLoading + ? Text( chatProvider.isStopping ? "Stopping..." : "Stop", ) - : const Text("Send"), + : const Text("Send"), + ), + ], + ), ), ], ), - ), - ], + ], + ), ), - ], - ), + ) + ], ), ), ], diff --git a/lib/ui/providers/chat_provider.dart b/lib/ui/providers/chat_provider.dart index 7bf98b9..3a31f79 100644 --- a/lib/ui/providers/chat_provider.dart +++ b/lib/ui/providers/chat_provider.dart @@ -61,6 +61,7 @@ class ChatProvider extends ChangeNotifier { try { _stopRequested = false; + bool hasStreamingAssistantMessage = false; _client = await OpenRouterClientFactory.create(apiKey: apiKey); final session = _conversationHistory!.session; final workingDirectory = session?.workingDirectory; @@ -100,15 +101,31 @@ class ChatProvider extends ChangeNotifier { _messages = _conversationHistory!.getMessages(); notifyListeners(); }, + onAssistantTextDelta: (delta) { + if (!hasStreamingAssistantMessage) { + _conversationHistory!.addMessage("assistant", ""); + hasStreamingAssistantMessage = true; + } + _conversationHistory!.appendToLastMessage(delta); + _messages = _conversationHistory!.getMessages(); + notifyListeners(); + }, + onAssistantMessageComplete: () { + hasStreamingAssistantMessage = false; + _messages = _conversationHistory!.getMessages(); + notifyListeners(); + }, ); _apiMessages = toolLoopResult.apiMessages; // add assistant message to visible conversation - _conversationHistory!.addMessage( - "assistant", - toolLoopResult.responseText, - tokens: toolLoopResult.response.outputTokens, - ); + if (!toolLoopResult.finalResponseWasStreamed) { + _conversationHistory!.addMessage( + "assistant", + toolLoopResult.responseText, + tokens: toolLoopResult.response.outputTokens, + ); + } _messages = _conversationHistory!.getMessages(); // track cost (set to 0 for now — OpenRouter pricing varies by model) diff --git a/lib/ui/widgets/app_header.dart b/lib/ui/widgets/app_header.dart index b506a3d..1cfe6a4 100644 --- a/lib/ui/widgets/app_header.dart +++ b/lib/ui/widgets/app_header.dart @@ -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) { diff --git a/lib/ui/widgets/chat_view.dart b/lib/ui/widgets/chat_view.dart index 7813fa1..b09ee38 100644 --- a/lib/ui/widgets/chat_view.dart +++ b/lib/ui/widgets/chat_view.dart @@ -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 { late ScrollController _scrollController; + List _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( 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, + ), + ), + ], + ), + ), + ), + ), + ], ); }, ); } -} +} \ No newline at end of file diff --git a/lib/ui/widgets/cost_badge.dart b/lib/ui/widgets/cost_badge.dart deleted file mode 100644 index 4e3a1f1..0000000 --- a/lib/ui/widgets/cost_badge.dart +++ /dev/null @@ -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( - 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), - ), - ), - ); - }, - ); - } -} diff --git a/lib/ui/widgets/input_bar.dart b/lib/ui/widgets/input_bar.dart deleted file mode 100644 index 48bc6a8..0000000 --- a/lib/ui/widgets/input_bar.dart +++ /dev/null @@ -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 createState() => _InputBarState(); -} - -class _InputBarState extends State { - 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( - 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"), - ), - ], - ), - ); - }, - ); - } -} diff --git a/lib/ui/widgets/message_bubble.dart b/lib/ui/widgets/message_bubble.dart index 264a92d..e793935 100644 --- a/lib/ui/widgets/message_bubble.dart +++ b/lib/ui/widgets/message_bubble.dart @@ -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 ), ); } diff --git a/lib/ui/widgets/sidebar.dart b/lib/ui/widgets/sidebar.dart deleted file mode 100644 index cd88060..0000000 --- a/lib/ui/widgets/sidebar.dart +++ /dev/null @@ -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( - 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(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(context, listen: false); - chatProvider.setConversation( - sessionProvider.getConversationHistory(), - ); - } - }, - child: const Text("+ New"), - ), - ), - ], - ); - }, - ); - } -}