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