Add initial project files and configurations for clawd_code
This commit is contained in:
parent
c88a1badc7
commit
fa4415553d
14 changed files with 763 additions and 459 deletions
59
SCROLLING_FIX_SUMMARY.md
Normal file
59
SCROLLING_FIX_SUMMARY.md
Normal 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.
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
],
|
)
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,49 +13,193 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
// Update previous message state for next build
|
||||||
controller: _scrollController,
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
itemCount: chatProvider.messages.length,
|
_previousMessageContents = currentMessages.map((m) => m.content).toList();
|
||||||
itemBuilder: (context, index) {
|
});
|
||||||
final message = chatProvider.messages[index];
|
|
||||||
return MessageBubble(message: message);
|
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: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,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue