Add initial project files and configurations for clawd_code

This commit is contained in:
ImBenji 2026-04-04 05:46:34 +01:00
parent c88a1badc7
commit fa4415553d
14 changed files with 763 additions and 459 deletions

59
SCROLLING_FIX_SUMMARY.md Normal file
View file

@ -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.

View file

@ -103,6 +103,218 @@ class OpenRouterClient {
return ResponseParser.parseOpenRouterResponse(response); return ResponseParser.parseOpenRouterResponse(response);
} }
Future<ApiMessage> createStreamingMessage({
required String model,
required int maxTokens,
required List<Map<String, dynamic>> messages,
String? system,
double? temperature,
List<Map<String, dynamic>>? tools,
String? toolChoice,
void Function(String delta)? onTextDelta,
}) async {
final requestBody = <String, dynamic>{
"model": model,
"max_tokens": maxTokens,
"messages": messages,
"stream": true,
"stream_options": <String, dynamic>{"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 = <int, _StreamingToolCallBuilder>{};
String responseId = "";
String responseModel = model;
String? finishReason;
Map<String, dynamic>? 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<String, dynamic>) {
usage = rawUsage;
}
final choices = event["choices"];
if (choices is! List || choices.isEmpty) {
continue;
}
final firstChoice = choices.first;
if (firstChoice is! Map<String, dynamic>) {
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<String, dynamic>) {
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<String, dynamic>) {
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<String, dynamic>) {
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 = <Map<String, dynamic>>[];
final text = textBuffer.toString();
if (text.isNotEmpty) {
contentBlocks.add(<String, dynamic>{"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(<String, dynamic>{
"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 // List available models
Future<List<Map<String, dynamic>>> listModels() async { Future<List<Map<String, dynamic>>> listModels() async {
final response = await _makeRequest(method: "GET", endpoint: "/models"); 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<String, dynamic> get parsedArguments {
final raw = arguments.toString();
if (raw.isEmpty) {
return <String, dynamic>{};
}
try {
final decoded = jsonDecode(raw);
return decoded is Map<String, dynamic> ? decoded : <String, dynamic>{};
} catch (_) {
return <String, dynamic>{};
}
}
}
class RequestCancelledException implements Exception { class RequestCancelledException implements Exception {
const RequestCancelledException(); const RequestCancelledException();

View file

@ -1,6 +1,8 @@
// Response parser for Anthropic Message API responses // Response parser for Anthropic Message API responses
// Ported from old_repo/services/api/errors.ts and claude.ts // Ported from old_repo/services/api/errors.ts and claude.ts
import "dart:convert";
import "api_types.dart"; import "api_types.dart";
// Parse Message API response into ApiMessage model // Parse Message API response into ApiMessage model
@ -145,12 +147,15 @@ class ErrorParser {
class StreamingResponseParser { class StreamingResponseParser {
// parse a streamed event from newline-delimited JSON // parse a streamed event from newline-delimited JSON
static Map<String, dynamic>? parseStreamLine(String line) { static Map<String, dynamic>? parseStreamLine(String line) {
if (line.trim().isEmpty) return null; final trimmed = line.trim();
if (trimmed.isEmpty || trimmed.startsWith(":")) return null;
try { try {
// handle SSE format (data: {...}) // handle SSE format (data: {...})
final data = line.startsWith("data: ") ? line.substring(6) : line; final data = line.startsWith("data: ") ? line.substring(6) : line;
// simple JSON parsing - in production would use json.decode if (data.trim() == "[DONE]") {
return <String, dynamic>{"type": "done"};
}
return _parseJson(data); return _parseJson(data);
} catch (_) { } catch (_) {
return null; return null;
@ -158,9 +163,8 @@ class StreamingResponseParser {
} }
static Map<String, dynamic>? _parseJson(String jsonStr) { static Map<String, dynamic>? _parseJson(String jsonStr) {
// stubbed - would use dart:convert.jsonDecode in real impl final decoded = jsonDecode(jsonStr);
// for now just return null to indicate parsing would happen return decoded is Map<String, dynamic> ? decoded : null;
return null;
} }
// check if streamed event is a message delta (partial response) // check if streamed event is a message delta (partial response)
@ -177,6 +181,11 @@ class StreamingResponseParser {
return type == "message_stop"; return type == "message_stop";
} }
static bool isDone(Map<String, dynamic>? event) {
if (event == null) return false;
return event["type"] == "done";
}
// extract partial text from delta event // extract partial text from delta event
static String? extractDeltaText(Map<String, dynamic>? event) { static String? extractDeltaText(Map<String, dynamic>? event) {
if (event == null) return null; if (event == null) return null;

View file

@ -13,11 +13,13 @@ class ToolLoopResult {
required this.apiMessages, required this.apiMessages,
required this.responseText, required this.responseText,
required this.response, required this.response,
required this.finalResponseWasStreamed,
}); });
final List<Map<String, dynamic>> apiMessages; final List<Map<String, dynamic>> apiMessages;
final String responseText; final String responseText;
final ApiMessage response; final ApiMessage response;
final bool finalResponseWasStreamed;
} }
class ToolLoopException implements Exception { class ToolLoopException implements Exception {
@ -48,6 +50,8 @@ class ToolLoopService {
String? workingDirectory, String? workingDirectory,
void Function(String toolName, Map<String, dynamic> input)? onToolCall, void Function(String toolName, Map<String, dynamic> input)? onToolCall,
void Function(String toolName, String result)? onToolResult, void Function(String toolName, String result)? onToolResult,
void Function(String delta)? onAssistantTextDelta,
void Function()? onAssistantMessageComplete,
}) async { }) async {
final updatedMessages = List<Map<String, dynamic>>.from(apiMessages) final updatedMessages = List<Map<String, dynamic>>.from(apiMessages)
..add(<String, dynamic>{"role": "user", "content": userText}); ..add(<String, dynamic>{"role": "user", "content": userText});
@ -56,14 +60,22 @@ class ToolLoopService {
try { try {
while (true) { while (true) {
lastResponse = await client.createMessage( bool streamedTextThisIteration = false;
lastResponse = await client.createStreamingMessage(
model: model, model: model,
maxTokens: 4096, maxTokens: 4096,
messages: updatedMessages, messages: updatedMessages,
system: _buildSystemPrompt(workingDirectory), system: _buildSystemPrompt(workingDirectory),
tools: _buildToolDefinitions(), tools: _buildToolDefinitions(),
toolChoice: "auto", toolChoice: "auto",
onTextDelta: (delta) {
streamedTextThisIteration = true;
onAssistantTextDelta?.call(delta);
},
); );
if (streamedTextThisIteration) {
onAssistantMessageComplete?.call();
}
updatedMessages.add(_assistantMessageForApi(lastResponse)); updatedMessages.add(_assistantMessageForApi(lastResponse));
@ -78,6 +90,7 @@ class ToolLoopService {
? _buildEmptyAssistantFallback(lastResponse) ? _buildEmptyAssistantFallback(lastResponse)
: responseText, : responseText,
response: lastResponse, response: lastResponse,
finalResponseWasStreamed: streamedTextThisIteration,
); );
} }

View file

@ -30,6 +30,21 @@ class ConversationHistory {
_session!.updated = DateTime.now().toUtc(); _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() { void removeLastMessage() {
if (_session == null || _session!.messages.isEmpty) { if (_session == null || _session!.messages.isEmpty) {
return; return;

View file

@ -31,6 +31,11 @@ const List<SelectableAiModel> selectableAiModels = [
group: "Recommended", group: "Recommended",
id: "deepseek/deepseek-v3.2", id: "deepseek/deepseek-v3.2",
label: "DeepSeek v3.2", label: "DeepSeek v3.2",
),
SelectableAiModel(
group: "Recommended",
id: "qwen/qwen3-coder-next",
label: "Qwen3 Coder Next",
) )

View file

@ -2,17 +2,17 @@ import "package:file_picker/file_picker.dart";
import "package:provider/provider.dart"; import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart"; import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../src/project_store.dart"; import "../../../src/project_store.dart";
import "../../src/session/session_types.dart"; import "../../../src/session/session_types.dart";
import "../constants.dart"; import "../../constants.dart";
import "../providers/chat_provider.dart"; import "../../providers/chat_provider.dart";
import "../providers/cost_provider.dart"; import "../../providers/cost_provider.dart";
import "../providers/projects_provider.dart"; import "../../providers/projects_provider.dart";
import "../providers/session_provider.dart"; import "../../providers/session_provider.dart";
import "../providers/settings_provider.dart"; import "../../providers/settings_provider.dart";
import "../widgets/app_header.dart"; import "../../widgets/app_header.dart";
import "../widgets/chat_view.dart"; import "../../widgets/chat_view.dart";
import "../widgets/settings_sheet.dart"; import "../../widgets/settings_sheet.dart";
class NewHomeScreen extends StatefulWidget { class NewHomeScreen extends StatefulWidget {
const NewHomeScreen({super.key}); const NewHomeScreen({super.key});
@ -268,7 +268,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> {
const Gap(16), const Gap(16),
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 16), padding: EdgeInsets.symmetric(horizontal: 16),
child: GarageHeader(), child: AppHeader(),
), ),
Padding( Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
@ -332,179 +332,159 @@ class _NewHomeScreenState extends State<NewHomeScreen> {
), ),
const VerticalDivider(), const VerticalDivider(),
Expanded( Expanded(
child: Padding( child: Column(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 18), children: [
child: Column(
children: [ if (selectedProject != null && sessionProvider.currentSession != null)...[
Row( Padding(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.symmetric(
children: [ horizontal: 16,
Expanded( vertical: 12
child: Column( ),
crossAxisAlignment: CrossAxisAlignment.start, child: Row(
children: [ children: [
Text(
selectedProject?.name ?? "Choose a project", Icon(
style: const TextStyle( LucideIcons.messageCircle
fontSize: 24, ).iconSmall,
fontWeight: FontWeight.w700,
), Gap(8),
),
const Gap(6), Transform.translate(
Text( offset: Offset(0, -1),
sessionProvider.currentSession?.name ?? child: Row(
(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,
children: [ children: [
OutlinedContainer( Text(
padding: const EdgeInsets.symmetric( selectedProject.name
horizontal: 12, ).textSmall,
vertical: 10,
), Padding(
child: Text( padding: EdgeInsets.symmetric(horizontal: 8),
"Session cost ${costProvider.getFormattedTotalCost()}", child: Icon(
).textSmall, LucideIcons.slash
), ).iconX2Small,
const Gap(8),
IconButton.ghost(
onPressed: _openSettings,
icon: const Icon(LucideIcons.settings2),
), ),
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), Divider(),
TextField( ],
controller: _messageController,
minLines: 3,
maxLines: 6, const Gap(18),
enabled: !chatProvider.isLoading, Expanded(
placeholder: Text( child: ConstrainedBox(
selectedProject == null constraints: BoxConstraints(
? "Choose a project to start chatting" maxWidth: 600
: "Ask a question or type a message",
), ),
onSubmitted: chatProvider.isLoading child: Column(
? null children: [
: (_) => _sendMessage(), Expanded(
features: [ child: ClipRect(
InputFeature.below( child: chatProvider.messages.isEmpty
Row( ? _EmptyChatState(
children: [ projectName: selectedProject?.name,
IconButton.ghost( hasProject: selectedProject != null,
onPressed: _pickProjectDirectory, )
icon: const Icon(LucideIcons.folderSearch), : const ChatView(),
), ),
const Spacer(), ),
Select<String>( const Gap(16),
itemBuilder: (context, item) { TextField(
return Text(_modelLabel(item)); controller: _messageController,
}, minLines: 3,
popup: SelectPopup.builder( maxLines: 6,
searchPlaceholder: const Text("Search models"), enabled: !chatProvider.isLoading,
builder: (context, searchQuery) { placeholder: Text(
final filteredModels = searchQuery == null selectedProject == null
? _modelGroups.entries ? "Choose a project to start chatting"
: _filteredModels(searchQuery); : "Ask a question or type a message",
return SelectItemList( ),
children: [ onSubmitted: chatProvider.isLoading
for (final entry in filteredModels) ? null
SelectGroup( : (_) => _sendMessage(),
headers: [ features: [
SelectLabel(child: Text(entry.key)), InputFeature.below(
], Row(
children: [
IconButton.ghost(
onPressed: _pickProjectDirectory,
icon: const Icon(LucideIcons.folderSearch),
),
const Spacer(),
Select<String>(
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: [ children: [
for (final modelId in entry.value) for (final entry in filteredModels)
SelectItemButton( SelectGroup(
value: modelId, headers: [
child: Text( SelectLabel(child: Text(entry.key)),
_modelLabel(modelId), ],
), children: [
for (final modelId in entry.value)
SelectItemButton(
value: modelId,
child: Text(
_modelLabel(modelId),
),
),
],
), ),
], ],
), );
], },
); ),
}, onChanged: (value) {
), if (value != null) {
onChanged: (value) { settingsProvider.updateModel(value);
if (value != null) { }
settingsProvider.updateModel(value); },
} constraints: const BoxConstraints(minWidth: 220),
}, value: currentModel,
constraints: const BoxConstraints(minWidth: 220), placeholder: const Text("Select a model"),
value: currentModel, ),
placeholder: const Text("Select a model"), const Gap(10),
), Button.primary(
const Gap(10), onPressed: chatProvider.isLoading
Button.primary( ? _stopMessage
onPressed: chatProvider.isLoading : _sendMessage,
? _stopMessage child: chatProvider.isLoading
: _sendMessage, ? Text(
child: chatProvider.isLoading
? Text(
chatProvider.isStopping chatProvider.isStopping
? "Stopping..." ? "Stopping..."
: "Stop", : "Stop",
) )
: const Text("Send"), : const Text("Send"),
),
],
),
), ),
], ],
), ),
), ],
], ),
), ),
], )
), ],
), ),
), ),
], ],

View file

@ -61,6 +61,7 @@ class ChatProvider extends ChangeNotifier {
try { try {
_stopRequested = false; _stopRequested = false;
bool hasStreamingAssistantMessage = false;
_client = await OpenRouterClientFactory.create(apiKey: apiKey); _client = await OpenRouterClientFactory.create(apiKey: apiKey);
final session = _conversationHistory!.session; final session = _conversationHistory!.session;
final workingDirectory = session?.workingDirectory; final workingDirectory = session?.workingDirectory;
@ -100,15 +101,31 @@ class ChatProvider extends ChangeNotifier {
_messages = _conversationHistory!.getMessages(); _messages = _conversationHistory!.getMessages();
notifyListeners(); 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; _apiMessages = toolLoopResult.apiMessages;
// add assistant message to visible conversation // add assistant message to visible conversation
_conversationHistory!.addMessage( if (!toolLoopResult.finalResponseWasStreamed) {
"assistant", _conversationHistory!.addMessage(
toolLoopResult.responseText, "assistant",
tokens: toolLoopResult.response.outputTokens, toolLoopResult.responseText,
); tokens: toolLoopResult.response.outputTokens,
);
}
_messages = _conversationHistory!.getMessages(); _messages = _conversationHistory!.getMessages();
// track cost (set to 0 for now OpenRouter pricing varies by model) // track cost (set to 0 for now OpenRouter pricing varies by model)

View file

@ -1,7 +1,7 @@
import "package:shadcn_flutter/shadcn_flutter.dart"; import "package:shadcn_flutter/shadcn_flutter.dart";
class GarageHeader extends StatelessWidget { class AppHeader extends StatelessWidget {
const GarageHeader({super.key}); const AppHeader({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View file

@ -1,5 +1,5 @@
import "package:flutter/widgets.dart";
import "package:provider/provider.dart"; import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../providers/chat_provider.dart"; import "../providers/chat_provider.dart";
import "message_bubble.dart"; import "message_bubble.dart";
@ -13,47 +13,191 @@ class ChatView extends StatefulWidget {
class _ChatViewState extends State<ChatView> { class _ChatViewState extends State<ChatView> {
late ScrollController _scrollController; late ScrollController _scrollController;
List<String> _previousMessageContents = [];
bool _isUserScrolling = false;
DateTime? _lastScrollTime;
bool _showJumpToBottom = false;
bool _hasNewMessagesWhileScrolledAway = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scrollController = ScrollController(); _scrollController = ScrollController();
_scrollController.addListener(_handleScroll);
} }
@override @override
void dispose() { void dispose() {
_scrollController.removeListener(_handleScroll);
_scrollController.dispose(); _scrollController.dispose();
super.dispose(); super.dispose();
} }
void _scrollToBottom() { void _handleScroll() {
WidgetsBinding.instance.addPostFrameCallback((_) { _lastScrollTime = DateTime.now();
if (_scrollController.hasClients) { _isUserScrolling = true;
_scrollController.animateTo(
_scrollController.position.maxScrollExtent, // Update whether to show jump-to-bottom button
duration: const Duration(milliseconds: 300), if (_scrollController.hasClients) {
curve: Curves.easeOut, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<ChatProvider>( return Consumer<ChatProvider>(
builder: (context, chatProvider, _) { builder: (context, chatProvider, _) {
// scroll to bottom when new messages arrive // Get current messages
if (chatProvider.messages.isNotEmpty) { final currentMessages = chatProvider.messages;
_scrollToBottom();
// 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;
}
}
} }
return ListView.builder( if (messagesChanged && currentMessages.isNotEmpty) {
controller: _scrollController, // Check if we're near the bottom
itemCount: chatProvider.messages.length, final nearBottom = _isNearBottom();
itemBuilder: (context, index) {
final message = chatProvider.messages[index]; if (nearBottom && !_isUserScrolling) {
return MessageBubble(message: message); // 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;
}
}
// 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,
),
),
],
),
),
),
),
],
); );
}, },
); );

View file

@ -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),
),
),
);
},
);
}
}

View file

@ -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"),
),
],
),
);
},
);
}
}

View file

@ -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:flutter_markdown/flutter_markdown.dart";
import "package:shadcn_flutter/shadcn_flutter.dart"; import "package:shadcn_flutter/shadcn_flutter.dart";
@ -14,47 +14,95 @@ class MessageBubble extends StatelessWidget {
final isUser = message.role == "user"; final isUser = message.role == "user";
final isTool = message.role == "tool"; final isTool = message.role == "tool";
final isAssistant = message.role == "assistant"; final isAssistant = message.role == "assistant";
final accentColor = isTool
? const Color(0xFF64748B) final theme = Theme.of(context);
: const Color(0xFF94A3B8);
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( return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container( child: Container(
constraints: BoxConstraints( child: Padding(
maxWidth: material.MediaQuery.of(context).size.width * 0.7, padding: const EdgeInsets.all(12),
), child: Column(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), crossAxisAlignment: CrossAxisAlignment.start,
child: Card( children: [
child: Padding( Text(
padding: const EdgeInsets.all(12), message.role,
child: material.Column( style: TextStyle(
crossAxisAlignment: CrossAxisAlignment.start, fontSize: 12,
children: [ fontWeight: FontWeight.w600,
Text(
message.role,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: accentColor,
),
), ),
const SizedBox(height: 4), ),
if (isAssistant || isTool) const SizedBox(height: 4),
MarkdownBody( if (isAssistant || isTool)
data: isTool MarkdownBody(
? _buildToolMarkdown(message.content) data: isTool
: message.content, ? _buildToolMarkdown(message.content)
selectable: true, : message.content,
shrinkWrap: true, selectable: true,
styleSheet: isTool shrinkWrap: true,
? _toolMarkdownStyleSheet(context) styleSheet: isTool
: null, ? _toolMarkdownStyleSheet(context)
) : null,
else )
Text(message.content), else
], Text(message.content),
), ],
), ),
), ),
), ),
@ -78,16 +126,9 @@ class MessageBubble extends StatelessWidget {
MarkdownStyleSheet _toolMarkdownStyleSheet(BuildContext context) { MarkdownStyleSheet _toolMarkdownStyleSheet(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return MarkdownStyleSheet.fromTheme(material.Theme.of(context)).copyWith( return MarkdownStyleSheet(
p: theme.typography.base.copyWith(height: 1.35), p: theme.typography.p.copyWith(
codeblockDecoration: BoxDecoration( fontSize: 13
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,
), ),
); );
} }

View file

@ -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"),
),
),
],
);
},
);
}
}