Update project structure and enhance functionality with new features and dependencies

This commit is contained in:
ImBenji
2026-04-14 03:31:29 +01:00
parent 0b6b604c56
commit 3588783001
63 changed files with 10565 additions and 789 deletions
+2 -2
View File
@@ -13,11 +13,11 @@ class ClawdApp extends StatelessWidget {
return Consumer<SettingsProvider>(
builder: (context, settingsProvider, _) {
return ShadcnApp.router(
title: "Clawd",
title: "The Agency",
routerConfig: AppRouter.router,
scaling: const AdaptiveScaling(0.9),
theme: ThemeData(
colorScheme: ColorSchemes.darkGray.rose,
colorScheme: ColorSchemes.darkGray,
density: Density.spaciousDensity,
radius: 0.5,
),
+12 -32
View File
@@ -14,33 +14,8 @@ const List<SelectableAiModel> selectableAiModels = [
SelectableAiModel(
group: "Recommended",
id: "qwen/qwen3-coder",
label: "Qwen3 Coder",
),
SelectableAiModel(
group: "Recommended",
id: "openai/gpt-oss-120b",
label: "GPT-OSS 120B",
),
SelectableAiModel(
group: "Recommended",
id: "meta-llama/llama-3.3-70b-instruct",
label: "LLaMA 3.3 70B Instruct",
),
SelectableAiModel(
group: "Recommended",
id: "deepseek/deepseek-v3.2",
label: "DeepSeek v3.2",
),
SelectableAiModel(
group: "Recommended",
id: "qwen/qwen3-coder-next",
label: "Qwen3 Coder Next",
),
SelectableAiModel(
group: "Recommended",
id: "qwen/qwen3-235b-a22b-2507",
label: "Qwen3 235B A22B-2507",
id: "qwen/qwen3.6-plus",
label: "Qwen3.6 Plus",
),
SelectableAiModel(
group: "Recommended",
@@ -49,14 +24,19 @@ const List<SelectableAiModel> selectableAiModels = [
),
SelectableAiModel(
group: "Recommended",
id: "qwen/qwen3.6-plus",
label: "Qwen3.6 Plus",
id: "openai/gpt-5.4-mini",
label: "GPT-5.4 Mini",
),
SelectableAiModel(
group: "Recommended",
id: "anthropic/claude-sonnet-4.6",
label: "Claude Sonnet 4.6",
)
id: "moonshotai/kimi-k2.5",
label: "Kimi K2.5",
),
SelectableAiModel(
group: "Recommended",
id: "google/gemini-3-flash-preview",
label: "Gemini 3 Flash Preview",
),
];
+96 -24
View File
@@ -6,11 +6,12 @@ import "../../../src/project_store.dart";
import "../../providers/chat_provider.dart";
import "../../providers/home_coordinator.dart";
import "../../providers/projects_provider.dart";
import "../../widgets/agents/agents_pane.dart";
import "../../widgets/chat/chat_box.dart";
import "../../widgets/chat/chat_view.dart";
import "../../widgets/common/footer_bar.dart";
import "../../widgets/common/app_header.dart";
import "../../widgets/sidebar/sidebar.dart";
import "../../widgets/sidebar/sidebar_v2.dart";
class NewHomeScreen extends StatefulWidget {
const NewHomeScreen({super.key});
@@ -19,6 +20,16 @@ class NewHomeScreen extends StatefulWidget {
State<NewHomeScreen> createState() => _NewHomeScreenState();
}
Color _centerBgColor(BuildContext context) {
final theme = Theme.of(context);
final dark = theme.brightness == Brightness.dark;
final h = HSLColor.fromColor(theme.colorScheme.border).hue;
return dark
? HSLColor.fromAHSL(1, h, 0.35, 0.13).toColor()
: HSLColor.fromAHSL(1, h, 0.30, 0.88).toColor();
}
class _NewHomeScreenState extends State<NewHomeScreen> {
final ScrollController _chatScrollController = ScrollController();
@@ -68,37 +79,40 @@ class _NewHomeScreenState extends State<NewHomeScreen> {
return Scaffold(
child: Column(
children: [
AppHeader(),
Expanded(
child: Row(
children: [
child: ColoredBox(
color: _centerBgColor(context),
child: Stack(
children: [
Sidebar(),
_ChatArea(scrollController: _chatScrollController),
Gap(1),
Positioned(
top: 0,
bottom: 0,
right: 0,
width: 12,
child: ChatScrollBar(controller: _chatScrollController),
),
VerticalDivider(),
Expanded(
child: Stack(
children: [
_ChatArea(scrollController: _chatScrollController),
Positioned(
top: 0,
bottom: 0,
right: 0,
width: 12,
child: FullHeightScrollbar(controller: _chatScrollController),
),
],
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(painter: _InsetShadowPainter()),
),
),
AgentsPane(),
Positioned(
top: 12,
left: 12,
bottom: 12,
child: _SidebarPane(),
),
],
],
),
),
),
@@ -223,6 +237,64 @@ class _EmptyChatState extends StatelessWidget {
}
class _InsetShadowPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
const blur = 12.0;
final rect = Offset.zero & size;
canvas.save();
canvas.clipRect(rect);
// restrict to outer ring so shadow doesnt bleed into center
final innerRect = rect.deflate(24);
final ringClip = Path()
..addRect(rect)
..addRect(innerRect)
..fillType = PathFillType.evenOdd;
canvas.clipPath(ringClip);
final path = Path()
..addRect(rect.inflate(blur * 2))
..addRect(rect)
..fillType = PathFillType.evenOdd;
final paint = Paint()
..color = const Color(0x55000000)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, blur);
canvas.drawPath(path, paint);
canvas.restore();
}
@override
bool shouldRepaint(covariant CustomPainter old) => false;
}
class _SidebarPane extends StatelessWidget {
const _SidebarPane();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return OutlinedContainer(
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 16,
spreadRadius: 2,
),
],
child: SidebarV2(),
);
}
}
abstract class HomeScreenRoute {
static const path = '/';
static const name = 'home';
+122 -319
View File
@@ -1,49 +1,38 @@
import "package:flutter/foundation.dart";
import "dart:convert";
import "dart:typed_data";
import "../../src/chat/tool_loop_service.dart";
import "../../src/api/openrouter_client.dart";
import "../../src/compact/compact_service.dart";
import "../../src/hooks/hook_loader.dart";
import "../../src/hooks/hook_runner.dart";
import "../../src/hooks/hook_types.dart";
import "../../src/permissions/permission_types.dart";
import "../../src/session/conversation_history.dart";
import "../../src/session/session_store.dart";
import "../../src/session/session_runtime.dart";
import "../../src/session/session_types.dart";
import "../../src/services/cost_tracker.dart" as cost_tracker;
import "../models/attachment.dart";
import "settings_provider.dart";
enum QueuePriority {
now(0),
next(1),
later(2);
final int order;
const QueuePriority(this.order);
}
class QueuedMessage {
final String text;
final QueuePriority priority;
const QueuedMessage({required this.text, required this.priority});
}
// ChatProvider is now a thin registry over SessionRuntime instances.
//
// Each thread gets its own SessionRuntime which holds all the mutable state
// that used to live here — api messages, the http client, loading flags, etc.
// Switching threads just changes _activeSessionId. Background threads keep
// running and save themselves to disk; when you switch back you see their
// live state.
class ChatProvider extends ChangeNotifier {
ChatProvider(this._settingsProvider) {
_initHooks();
}
final SettingsProvider _settingsProvider;
ToolLoopService _toolLoopService = ToolLoopService();
HookRunner? _hookRunner;
ConversationHistory? _conversationHistory;
OpenRouterClient? _client;
bool _stopRequested = false;
PendingPermission? _pendingPermission;
PendingPermission? get pendingPermission => _pendingPermission;
final Map<String, SessionRuntime> _runtimes = {};
String? _activeSessionId;
// ─── hooks ──────────────────────────────────────────────────────────────────
Future<void> _initHooks() async {
try {
@@ -51,22 +40,48 @@ class ChatProvider extends ChangeNotifier {
_hookRunner = HookRunner(hooks: hooks);
_toolLoopService = ToolLoopService(hookRunner: _hookRunner);
} catch (e) {
// hooks are optional, carry on without them
print("Hook init failed: $e");
}
}
List<Map<String, dynamic>> _apiMessages = <Map<String, dynamic>>[];
bool isLoading = false;
final List<QueuedMessage> _messageQueue = [];
// ─── active runtime accessors ────────────────────────────────────────────────
List<Message> get messages => _conversationHistory?.getMessages() ?? const [];
SessionRuntime? get _active =>
_activeSessionId != null ? _runtimes[_activeSessionId] : null;
List<Message> get messages => _active?.messages ?? const [];
int get messageCount => messages.length;
String? get workingDirectory => _conversationHistory?.session?.workingDirectory;
String? get workingDirectory => _active?.workingDirectory;
bool get hasConversation => _active != null;
bool get isLoading => _active?.isLoading ?? false;
bool get isCompacting => _active?.isCompacting ?? false;
bool get isStopping => _active?.isStopping ?? false;
int get queuedMessageCount => _active?.queuedMessageCount ?? 0;
List<String> get queuedMessages => _active?.queuedMessages ?? const [];
PendingPermission? get pendingPermission => _active?.pendingPermission;
String? get lastCompactSummary => _active?.lastCompactSummary;
TokenWarningState? get tokenWarningState => _active?.tokenWarningState;
String get threadPermissionMode => _active?.permissionModeOverride ?? "default";
Future<void> setThreadPermissionMode(String mode) =>
_active?.setPermissionModeOverride(mode) ?? Future.value();
bool isSessionRunning(String sessionId) {
final r = _runtimes[sessionId];
return r != null && (r.isLoading || r.isCompacting);
}
bool sessionNeedsAttention(String sessionId) {
final r = _runtimes[sessionId];
return r != null && r.pendingPermission != null;
}
bool sessionHasUnreadResult(String sessionId) {
final r = _runtimes[sessionId];
return r != null && r.hasUnreadResult;
}
/// Context window size from the last API response — derived from persisted
/// message data, same as Claude Code (walks backwards to find the last
/// assistant message that has contextTokens set).
int get contextTokens {
final msgs = messages;
for (var i = msgs.length - 1; i >= 0; i--) {
@@ -75,304 +90,92 @@ class ChatProvider extends ChangeNotifier {
}
return 0;
}
bool get hasConversation => _conversationHistory != null;
bool get isStopping => _stopRequested;
int get queuedMessageCount => _messageQueue.length;
// only user-visible messages (priority != now)
List<String> get queuedMessages =>
List.unmodifiable(_messageQueue.map((m) => m.text));
// ─── session lifecycle ───────────────────────────────────────────────────────
void removeQueuedMessage(int index) {
if (index < 0 || index >= _messageQueue.length) return;
_messageQueue.removeAt(index);
notifyListeners();
}
// Called when the user switches to (or creates) a session.
// Creates a new runtime if one doesn't already exist for this session.
void activateSession(ConversationSession session) {
final id = session.id;
QueuedMessage? _dequeue() {
if (_messageQueue.isEmpty) return null;
int bestIdx = 0;
for (int i = 1; i < _messageQueue.length; i++) {
if (_messageQueue[i].priority.order < _messageQueue[bestIdx].priority.order) {
bestIdx = i;
}
}
final cmd = _messageQueue[bestIdx];
_messageQueue.removeAt(bestIdx);
return cmd;
}
void setConversation(ConversationHistory history) {
_conversationHistory = history;
_apiMessages = _buildApiMessages(history.getMessages());
notifyListeners();
}
void clearConversation() {
_conversationHistory = null;
_apiMessages = <Map<String, dynamic>>[];
_messageQueue.clear();
isLoading = false;
notifyListeners();
}
Future<void> sendMessage(String text, {QueuePriority priority = QueuePriority.next}) async {
if (text.isEmpty || _conversationHistory == null) return;
if (isLoading) {
_messageQueue.add(QueuedMessage(text: text, priority: priority));
notifyListeners();
return;
}
final apiKey = _settingsProvider.settings.openRouterApiKey;
if (apiKey == null || apiKey.isEmpty) {
throw Exception(
"OpenRouter API key not set. Please configure it in settings.",
);
}
final savedModel = _settingsProvider.settings.model;
final model = _settingsProvider.normalizeModelId(savedModel);
if (savedModel != model) {
print("Normalizing legacy model ID from $savedModel to $model");
await _settingsProvider.updateModel(model);
}
try {
_stopRequested = false;
bool hasStreamingAssistantMessage = false;
_client = await OpenRouterClientFactory.create(apiKey: apiKey);
final session = _conversationHistory!.session;
final workingDirectory = session?.workingDirectory;
if (session != null) {
session.model = model;
if (session.name == "New Chat") {
session.name = _buildSessionName(text);
}
}
// fire UserPromptSubmit hook
await _hookRunner?.runHooksForKind(
HookKind.userPromptSubmit,
input: {"message": text},
);
// add user message to conversation
_conversationHistory!.addMessage("user", text);
_apiMessages.add(<String, dynamic>{"role": "user", "content": text});
isLoading = true;
notifyListeners();
final advisorModel = _settingsProvider.settings.advisorModel;
final toolLoopResult = await _toolLoopService.runTurn(
client: _client!,
model: model,
apiKey: apiKey,
if (!_runtimes.containsKey(id)) {
_runtimes[id] = SessionRuntime(
session: session,
toolLoopService: _toolLoopService,
hookRunner: _hookRunner,
getSettings: () => _settingsProvider.settings,
apiMessages: _apiMessages.take(_apiMessages.length - 1).toList(),
userText: text,
workingDirectory: workingDirectory,
advisorModel: advisorModel,
onToolCall: (toolName, input) {
_conversationHistory!.addMessage(
"tool",
_formatToolCall(toolName, input),
);
notifyListeners();
},
onToolResult: (toolName, result) {
_conversationHistory!.addMessage(
"tool",
_formatToolResult(toolName, result),
);
notifyListeners();
},
onAssistantTextDelta: (delta) {
if (!hasStreamingAssistantMessage) {
_conversationHistory!.addMessage("assistant", "");
hasStreamingAssistantMessage = true;
}
_conversationHistory!.appendToLastMessage(delta);
notifyListeners();
},
onAssistantMessageComplete: () {
hasStreamingAssistantMessage = false;
notifyListeners();
},
onPermissionRequired: (toolName, input) async {
final pending = PendingPermission(toolName: toolName, input: input);
_pendingPermission = pending;
notifyListeners();
final decision = await pending.future;
_pendingPermission = null;
notifyListeners();
return decision;
},
normalizeModelId: (m) => _settingsProvider.normalizeModelId(m),
onChanged: notifyListeners,
);
_apiMessages = toolLoopResult.apiMessages;
final ct = toolLoopResult.response.contextTokens;
// add assistant message to visible conversation
if (!toolLoopResult.finalResponseWasStreamed) {
_conversationHistory!.addMessage(
"assistant",
toolLoopResult.responseText,
tokens: toolLoopResult.response.outputTokens,
contextTokens: ct,
);
} else {
// streamed message was built incrementally — patch contextTokens onto it
_conversationHistory!.setLastMessageContextTokens(ct);
}
// track cost (set to 0 for now — OpenRouter pricing varies by model)
final inputTokens = toolLoopResult.response.inputTokens ?? 0;
final outputTokens = toolLoopResult.response.outputTokens ?? 0;
cost_tracker.addToTotalSessionCost(
cost: 0.0,
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheReadTokens: 0,
cacheCreationTokens: 0,
webSearchRequests: toolLoopResult.webSearchRequests,
webFetchRequests: toolLoopResult.webFetchRequests,
model: toolLoopResult.response.model,
);
// save session
if (session != null) {
await SessionStore.instance.saveSession(session);
}
notifyListeners();
} catch (error, stackTrace) {
print("Failed to send message: $error");
print(stackTrace);
if (error is RequestCancelledException) {
_conversationHistory!.addMessage("assistant", "Generation stopped.");
final session = _conversationHistory!.session;
if (session != null) {
await SessionStore.instance.saveSession(session);
}
return;
}
if (error is ToolLoopException) {
_apiMessages = List<Map<String, dynamic>>.from(error.apiMessages);
}
_conversationHistory!.addMessage(
"assistant",
_buildTurnFailureMessage(error),
);
final session = _conversationHistory!.session;
if (session != null) {
await SessionStore.instance.saveSession(session);
}
rethrow;
} finally {
_client?.close();
_client = null;
_stopRequested = false;
isLoading = false;
notifyListeners();
}
final next = _dequeue();
if (next != null) {
notifyListeners();
await sendMessage(next.text, priority: next.priority);
}
}
void resolvePermission(PermissionDecision decision) async {
final pending = _pendingPermission;
if (pending == null) return;
if (decision == PermissionDecision.allowAlways) {
// persist to settings so this tool is auto-allowed from now on
await _settingsProvider.addAlwaysAllowRule(pending.toolName);
}
pending.resolve(decision);
_pendingPermission = null;
_activeSessionId = id;
_runtimes[id]?.markRead();
notifyListeners();
}
void stopGenerating() {
if (!isLoading) {
return;
// Fast-path: switch focus to an already-running runtime without touching disk.
void activateSessionById(String sessionId) {
if (_runtimes.containsKey(sessionId)) {
_activeSessionId = sessionId;
_runtimes[sessionId]?.markRead();
notifyListeners();
}
_pendingPermission?.resolve(PermissionDecision.reject);
_pendingPermission = null;
_messageQueue.clear();
_stopRequested = true;
print("Stopping active turn");
_client?.cancelActiveRequest();
notifyListeners();
_hookRunner?.runHooksForKind(HookKind.stop);
}
// Called when the user starts a new blank chat — no session exists yet.
void clearConversation() {
_activeSessionId = null;
// prune dead runtimes that are done
_runtimes.removeWhere((_, r) => !r.isLoading && !r.isCompacting);
notifyListeners();
}
// Legacy compat — kept so HomeCoordinator doesn't need parallel changes
// for paths that still call this. Routes to activateSession.
void setConversation(ConversationSession session) => activateSession(session);
// ─── actions — delegate to active runtime ───────────────────────────────────
Future<void> sendMessage(
String text, {
QueuePriority priority = QueuePriority.next,
List<Attachment>? attachments,
}) async {
final runtime = _active;
if (runtime == null) return;
final adapted = attachments
?.map((a) => AttachmentData(
name: a.name,
mimeType: a.mimeType,
data: a.data,
))
.toList();
await runtime.sendMessage(text, priority: priority, attachments: adapted);
}
void stopGenerating() => _active?.stopGenerating();
Future<void> runCompact({String? customInstructions}) =>
_active?.runCompact(customInstructions: customInstructions) ??
Future.value();
Future<void> resolvePermission(PermissionDecision decision) =>
_active?.resolvePermission(decision) ?? Future.value();
void removeQueuedMessage(int index) => _active?.removeQueuedMessage(index);
// ─── dispose ────────────────────────────────────────────────────────────────
@override
void dispose() {
_client?.close();
for (final r in _runtimes.values) {
r.dispose();
}
super.dispose();
}
String _buildSessionName(String text) {
final sanitized = text.replaceAll(RegExp(r"\s+"), " ").trim();
if (sanitized.isEmpty) {
return "New Chat";
}
const maxLength = 48;
if (sanitized.length <= maxLength) {
return sanitized;
}
return "${sanitized.substring(0, maxLength - 1).trimRight()}";
}
List<Map<String, dynamic>> _buildApiMessages(List<Message> messages) {
return messages
.where(
(message) => message.role == "user" || message.role == "assistant",
)
.map(
(message) => <String, dynamic>{
"role": message.role,
"content": message.content,
},
)
.toList(growable: true);
}
String _formatToolCall(String toolName, Map<String, dynamic> input) {
const encoder = JsonEncoder.withIndent(" ");
final visibleInput = Map<String, dynamic>.fromEntries(
input.entries.where((entry) => !entry.key.startsWith("_")),
);
return "$toolName call\n${encoder.convert(visibleInput)}";
}
String _formatToolResult(String toolName, String result) {
return "$toolName result\n$result";
}
String _buildTurnFailureMessage(Object error) {
return "This turn failed before the assistant could finish: $error";
}
}
+28 -13
View File
@@ -4,6 +4,7 @@ import "package:flutter/foundation.dart";
import "../../src/project_store.dart";
import "../../src/session/session_types.dart";
import "chat_provider.dart";
import "../models/attachment.dart";
import "projects_provider.dart";
import "session_provider.dart";
import "settings_provider.dart";
@@ -56,20 +57,17 @@ class HomeCoordinator extends ChangeNotifier {
}
}
Future<void> createNewChat() async {
void createNewChat() {
final selectedProject = _projects.selectedProject;
if (selectedProject == null) {
_setError("Choose a project first so the new chat has a working directory.");
return;
}
await _session.createNewSession(
workingDirectory: selectedProject.workingDirectory,
name: "New Chat",
model: _settings.settings.model,
);
_settings.setThreadModel(_settings.settings.model);
_chat.setConversation(_session.getConversationHistory());
// Don't create the session yet — that happens on first message send.
// Just clear the current state so the UI shows a blank chat.
_session.clearCurrentSession(workingDirectory: selectedProject.workingDirectory);
_chat.clearConversation();
}
Future<void> selectProject(ProjectRecord project) async {
@@ -84,14 +82,28 @@ class HomeCoordinator extends ChangeNotifier {
}
Future<void> openSession(SessionSummary session) async {
// If a live runtime exists for this session, just switch focus to it
// without reloading from disk — avoids disrupting an in-progress turn.
if (_chat.isSessionRunning(session.id)) {
_chat.activateSessionById(session.id);
_session.setActiveSessionId(session.id);
_projects.selectProjectByWorkingDirectory(session.workingDirectory);
_settings.setThreadModel(session.model);
return;
}
await _session.loadSession(session);
_chat.setConversation(_session.getConversationHistory());
final loaded = _session.currentSession;
if (loaded != null) {
_chat.activateSession(loaded);
}
_projects.selectProjectByWorkingDirectory(_session.activeWorkingDirectory);
_settings.setThreadModel(_session.currentSession?.model);
}
Future<void> sendMessage(String text) async {
if (text.isEmpty) return;
Future<void> sendMessage(String text, {List<Attachment>? attachments}) async {
final hasAttachments = attachments != null && attachments.isNotEmpty;
if (text.isEmpty && !hasAttachments) return;
if (_session.currentSession == null) {
final selectedProject = _projects.selectedProject;
@@ -105,11 +117,14 @@ class HomeCoordinator extends ChangeNotifier {
model: _settings.settings.model,
);
_settings.setThreadModel(_settings.settings.model);
_chat.setConversation(_session.getConversationHistory());
final newSession = _session.currentSession;
if (newSession != null) {
_chat.activateSession(newSession);
}
}
try {
await _chat.sendMessage(text);
await _chat.sendMessage(text, attachments: attachments);
} catch (e, st) {
print("Failed to send message: $e");
print(st);
+5
View File
@@ -25,6 +25,11 @@ class SessionProvider extends ChangeNotifier {
ConversationSession? get currentSession => _currentSession;
String? get activeWorkingDirectory => _activeWorkingDirectory;
void setActiveSessionId(String id) {
_currentSessionId = id;
notifyListeners();
}
List<SessionSummary> sessionsForWorkingDirectory(String? workingDirectory) {
final normalizedDirectory = workingDirectory?.trim();
if (normalizedDirectory == null || normalizedDirectory.isEmpty) {
+8
View File
@@ -90,6 +90,14 @@ class SettingsProvider extends ChangeNotifier {
notifyListeners();
}
Future<void> updatePermissionMode(String mode) async {
await _settingsStore.update(
(current) => current.copyWith(permissionMode: mode),
);
_globalSettings = _settingsStore.settings;
notifyListeners();
}
Future<void> addAlwaysAllowRule(String toolName) async {
final current = _globalSettings.alwaysAllowRules;
if (current.contains(toolName)) return;
@@ -111,7 +111,7 @@ class ToolBubbleBase extends StatelessWidget {
child: Button.outline(
leading: Icon(LucideIcons.check).iconSmall,
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.allowOnce),
child: Text("Allow").small,
child: Text("Yes").small,
),
),
@@ -121,7 +121,7 @@ class ToolBubbleBase extends StatelessWidget {
child: Button.outline(
leading: Icon(LucideIcons.checkCheck).iconSmall,
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.allowAlways),
child: Text("Allow always").small,
child: Text("Yes, for this session").small,
),
),
@@ -131,7 +131,7 @@ class ToolBubbleBase extends StatelessWidget {
child: Button.destructive(
leading: Icon(LucideIcons.x).iconSmall,
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.reject),
child: Text("Reject").small,
child: Text("No").small,
),
),
+47 -5
View File
@@ -1,18 +1,60 @@
import "dart:typed_data";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../models/attachment.dart";
import "../attachment_preview.dart";
import "../../../../src/session/session_types.dart";
class UserBubble extends StatelessWidget {
const UserBubble({super.key, required this.content});
const UserBubble({
super.key,
required this.content,
this.attachments,
});
final String content;
final List<MessageAttachment>? attachments;
@override
Widget build(BuildContext context) {
final atts = attachments;
return Align(
alignment: Alignment.centerRight,
child: OutlinedContainer(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
backgroundColor: Theme.of(context).colorScheme.border,
child: SelectableText(content),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (atts != null && atts.isNotEmpty) ...[
SingleChildScrollView(
scrollDirection: Axis.horizontal,
reverse: true,
child: Row(
children: [
for (final att in atts)
Padding(
padding: const EdgeInsets.only(left: 8),
child: AttachmentItem(
attachment: Attachment(
name: att.name,
mimeType: att.mimeType,
data: Uint8List.fromList(att.data),
),
onRemove: () {},
),
),
],
),
),
const Gap(6),
],
OutlinedContainer(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
backgroundColor: Theme.of(context).colorScheme.border,
child: SelectableText(content),
),
],
),
);
}
+127 -12
View File
@@ -214,6 +214,7 @@ class _ChatBoxState extends State<ChatBox> {
Widget _right(BuildContext context) {
final settings = context.read<SettingsProvider>();
final selectedModel = settings.normalizeModelId(settings.settings.model);
final isLoading = context.watch<ChatProvider>().isLoading;
return SizedBox(
height: 38,
@@ -256,16 +257,23 @@ class _ChatBoxState extends State<ChatBox> {
AspectRatio(
aspectRatio: 1,
child: AgcSecondaryButton(
enabled: _controller.text.isNotEmpty,
onPressed: () {
final text = _controller.text.trim();
if (text.isEmpty) return;
context.read<HomeCoordinator>().sendMessage(text);
_controller.clear();
},
child: Icon(LucideIcons.arrowUp),
),
child: (isLoading && _controller.text.isEmpty && _attachments.isEmpty)
? AgcSecondaryButton(
onPressed: () => context.read<ChatProvider>().stopGenerating(),
child: Icon(LucideIcons.octagonX),
)
: AgcSecondaryButton(
enabled: _controller.text.isNotEmpty || _attachments.isNotEmpty,
onPressed: () {
final text = _controller.text.trim();
if (text.isEmpty && _attachments.isEmpty) return;
final toSend = List.of(_attachments);
context.read<HomeCoordinator>().sendMessage(text, attachments: toSend);
_controller.clear();
setState(() => _attachments.clear());
},
child: Icon(LucideIcons.arrowUp),
),
),
],
),
@@ -343,6 +351,7 @@ class _ChatBoxState extends State<ChatBox> {
return Focus(
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.keyV &&
(HardwareKeyboard.instance.isControlPressed ||
@@ -363,9 +372,11 @@ class _ChatBoxState extends State<ChatBox> {
return KeyEventResult.handled;
} else {
final text = _controller.text.trim();
if (text.isNotEmpty) {
context.read<HomeCoordinator>().sendMessage(text);
if (text.isNotEmpty || _attachments.isNotEmpty) {
final toSend = List.of(_attachments);
context.read<HomeCoordinator>().sendMessage(text, attachments: toSend);
_controller.clear();
setState(() => _attachments.clear());
}
return KeyEventResult.handled;
}
@@ -374,6 +385,14 @@ class _ChatBoxState extends State<ChatBox> {
return KeyEventResult.ignored;
},
child: OutlinedContainer(
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 16,
spreadRadius: 2,
offset: Offset(0, 4),
),
],
child: ButtonGroup.vertical(
expands: true,
children: [
@@ -449,7 +468,103 @@ class _ChatBoxState extends State<ChatBox> {
},
),
const SizedBox(height: 6),
_PermissionModeSelector(),
],
);
}
}
class _PermissionModeSelector extends StatelessWidget {
const _PermissionModeSelector();
static const _modes = [
("default", "Ask Always"),
("acceptEdits", "Accept Edits"),
];
@override
Widget build(BuildContext context) {
final chat = context.watch<ChatProvider>();
final current = chat.threadPermissionMode;
final theme = Theme.of(context);
final mutedFg = theme.colorScheme.mutedForeground;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (int i = 0; i < _modes.length; i++) ...[
if (i > 0) const SizedBox(width: 4),
_ModeChip(
label: _modes[i].$2,
selected: current == _modes[i].$1,
mutedFg: mutedFg,
onTap: () => chat.setThreadPermissionMode(_modes[i].$1),
),
],
],
);
}
}
class _ModeChip extends StatefulWidget {
const _ModeChip({
required this.label,
required this.selected,
required this.mutedFg,
required this.onTap,
});
final String label;
final bool selected;
final Color mutedFg;
final VoidCallback onTap;
@override
State<_ModeChip> createState() => _ModeChipState();
}
class _ModeChipState extends State<_ModeChip> {
bool _hovering = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final fg = widget.selected
? theme.colorScheme.foreground
: widget.mutedFg;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovering = true),
onExit: (_) => setState(() => _hovering = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: widget.selected
? theme.colorScheme.secondary
: _hovering
? theme.colorScheme.secondary.withValues(alpha: 0.5)
: Colors.transparent,
borderRadius: BorderRadius.circular(theme.radiusSm),
),
child: Text(
widget.label,
style: TextStyle(fontSize: 11, color: fg, fontWeight: widget.selected ? FontWeight.w600 : FontWeight.normal),
),
),
),
);
}
}
+234 -236
View File
@@ -18,219 +18,97 @@ class ChatView extends StatefulWidget {
class _ChatViewState extends State<ChatView> {
ScrollController get _scrollController => widget.scrollController;
List<String> _previousMessageContents = [];
bool _isUserScrolling = false;
DateTime? _lastScrollTime;
bool _showJumpToBottom = false;
bool _hasNewMessagesWhileScrolledAway = false;
bool _autoScrollQueued = false;
int _lastMessageCount = 0;
List<String> _lastMessageSigs = const [];
@override
void initState() {
super.initState();
_scrollController.addListener(_handleScroll);
static const double _bottomThreshold = 56;
bool _isAtBottom() {
if (!_scrollController.hasClients) return true;
final pos = _scrollController.position;
return pos.maxScrollExtent - pos.pixels <= _bottomThreshold;
}
@override
void dispose() {
_scrollController.removeListener(_handleScroll);
super.dispose();
}
void _handleScroll() {
_lastScrollTime = DateTime.now();
_isUserScrolling = true;
if (_scrollController.hasClients) {
final position = _scrollController.position;
final isFarFromBottom = position.pixels < position.maxScrollExtent - 200;
if (isFarFromBottom != _showJumpToBottom) {
setState(() {
_showJumpToBottom = isFarFromBottom;
});
}
if (!isFarFromBottom) {
setState(() {
_hasNewMessagesWhileScrolledAway = false;
});
}
}
Future.delayed(const Duration(milliseconds: 150), () {
if (_lastScrollTime != null &&
DateTime.now().difference(_lastScrollTime!) >= const Duration(milliseconds: 150)) {
if (mounted) {
setState(() {
_isUserScrolling = false;
});
}
}
void _scheduleAutoScroll() {
if (_autoScrollQueued) return;
_autoScrollQueued = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_autoScrollQueued = false;
if (!mounted || !_scrollController.hasClients) return;
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
});
}
bool _isNearBottom() {
if (!_scrollController.hasClients) return false;
final position = _scrollController.position;
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, _) {
final currentMessages = chatProvider.messages;
bool messagesChanged = false;
if (currentMessages.length != _previousMessageContents.length) {
messagesChanged = true;
} else {
for (int i = 0; i < currentMessages.length; i++) {
if (currentMessages[i].content != _previousMessageContents[i]) {
messagesChanged = true;
break;
}
}
}
final currentSigs = currentMessages.map((m) => "${m.role}:${m.content}").toList(growable: false);
final messagesChanged = currentMessages.length != _lastMessageCount ||
currentSigs.length != _lastMessageSigs.length ||
!_listEquals(currentSigs, _lastMessageSigs);
if (messagesChanged && currentMessages.isNotEmpty) {
final nearBottom = _isNearBottom();
if (nearBottom && !_isUserScrolling) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
_hasNewMessagesWhileScrolledAway = false;
} else if (!nearBottom) {
_hasNewMessagesWhileScrolledAway = true;
}
if (_isAtBottom()) _scheduleAutoScroll();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_lastMessageCount = currentMessages.length;
_lastMessageSigs = currentSigs;
});
} else if (currentMessages.isEmpty) {
_lastMessageCount = 0;
_lastMessageSigs = const [];
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_previousMessageContents = currentMessages.map((m) => m.content).toList();
});
final entries = _buildEntries(currentMessages);
return Stack(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: ListView.builder(
controller: _scrollController,
physics: const ClampingScrollPhysics(),
itemCount: entries.length,
itemBuilder: (context, index) {
final entry = entries[index];
final pending = chatProvider.pendingPermission;
final isThisPending = pending != null &&
index == entries.length - 1 &&
entry is _ToolEntry &&
entry.toolName == pending.toolName;
Widget bubble;
if (entry is _MessageEntry) {
final msg = entry.message;
if (msg.role == "user") {
bubble = UserBubble(content: msg.content);
} else if (msg.role == "assistant") {
bubble = AssistantBubble(content: msg.content);
} else {
bubble = Text(msg.content);
}
} else if (entry is _ToolEntry) {
bubble = ToolBubble(
toolName: entry.toolName,
toolInput: entry.toolInput,
result: entry.result,
isPendingPermission: isThisPending,
);
} else {
bubble = const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: bubble,
);
},
ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: SingleChildScrollView(
controller: _scrollController,
physics: const ClampingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var index = 0; index < entries.length; index++)
Padding(
key: ValueKey('${entries[index].stableKey}#$index'),
padding: const EdgeInsets.only(bottom: 12),
child: _buildBubble(context, chatProvider, entries[index], index, entries.length),
),
],
),
),
),
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,
),
),
],
),
),
),
),
_JumpToBottomButton(scrollController: _scrollController),
],
);
},
);
}
// merge consecutive tool call + result messages into single entries
Widget _buildBubble(BuildContext context, ChatProvider chatProvider, _ChatEntry entry, int index, int total) {
final pending = chatProvider.pendingPermission;
final isThisPending = pending != null && index == total - 1 && entry is _ToolEntry && entry.toolName == pending.toolName;
if (entry is _MessageEntry) {
final msg = entry.message;
if (msg.role == "user") return UserBubble(content: msg.content, attachments: msg.attachments);
if (msg.role == "assistant") return AssistantBubble(content: msg.content);
return Text(msg.content);
}
if (entry is _ToolEntry) {
return ToolBubble(toolName: entry.toolName, toolInput: entry.toolInput, result: entry.result, isPendingPermission: isThisPending);
}
return const SizedBox.shrink();
}
List<_ChatEntry> _buildEntries(List<Message> messages) {
final result = <_ChatEntry>[];
int i = 0;
@@ -238,11 +116,8 @@ class _ChatViewState extends State<ChatView> {
final msg = messages[i];
if (msg.role == "tool") {
final firstLine = msg.content.split("\n").first.trim();
if (firstLine.endsWith(" call")) {
final (toolName, toolInput) = ToolBubble.parseContent(msg.content);
// check if next message is the matching result
String? toolResult;
if (i + 1 < messages.length) {
final next = messages[i + 1];
@@ -253,18 +128,10 @@ class _ChatViewState extends State<ChatView> {
i++;
}
}
result.add(_ToolEntry(
toolName: toolName,
toolInput: toolInput,
result: toolResult,
));
result.add(_ToolEntry(toolName: toolName, toolInput: toolInput, result: toolResult));
i++;
continue;
}
// orphan result or unknown tool message — skip it
// (already consumed as part of a call above, or genuinely standalone)
final (toolName, _) = ToolBubble.parseContent(msg.content);
result.add(_ToolEntry(toolName: toolName));
i++;
@@ -275,13 +142,27 @@ class _ChatViewState extends State<ChatView> {
}
return result;
}
bool _listEquals(List<String> a, List<String> b) {
if (identical(a, b)) return true;
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
}
sealed class _ChatEntry {}
sealed class _ChatEntry {
String get stableKey;
}
class _MessageEntry extends _ChatEntry {
_MessageEntry(this.message);
final Message message;
@override
String get stableKey => 'msg:${message.role}:${message.content}';
}
class _ToolEntry extends _ChatEntry {
@@ -289,22 +170,97 @@ class _ToolEntry extends _ChatEntry {
final String toolName;
final Map<String, dynamic>? toolInput;
final String? result;
}
class FullHeightScrollbar extends StatefulWidget {
final ScrollController controller;
const FullHeightScrollbar({super.key, required this.controller});
@override
State<FullHeightScrollbar> createState() => _FullHeightScrollbarState();
String get stableKey => 'tool:$toolName:${toolInput?.toString() ?? ""}:${result ?? ""}:${identityHashCode(this)}';
}
class _FullHeightScrollbarState extends State<FullHeightScrollbar> {
class _JumpToBottomButton extends StatefulWidget {
final ScrollController scrollController;
const _JumpToBottomButton({required this.scrollController});
@override
State<_JumpToBottomButton> createState() => _JumpToBottomButtonState();
}
class _JumpToBottomButtonState extends State<_JumpToBottomButton> {
bool _show = false;
static const double _bottomThreshold = 56;
@override
void initState() {
super.initState();
widget.scrollController.addListener(_onScroll);
}
@override
void dispose() {
widget.scrollController.removeListener(_onScroll);
super.dispose();
}
void _onScroll() {
if (!widget.scrollController.hasClients) return;
final pos = widget.scrollController.position;
final atBottom = pos.maxScrollExtent - pos.pixels <= _bottomThreshold;
final shouldShow = !atBottom;
if (_show != shouldShow) {
setState(() => _show = shouldShow);
}
}
void _jump() {
if (!widget.scrollController.hasClients) return;
widget.scrollController.jumpTo(widget.scrollController.position.maxScrollExtent);
}
@override
Widget build(BuildContext context) {
if (!_show) return const SizedBox.shrink();
return Positioned(
bottom: 16,
right: 16,
child: GestureDetector(
onTap: _jump,
child: Container(
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: Icon(LucideIcons.arrowDown, size: 16, color: Theme.of(context).colorScheme.foreground),
),
),
);
}
}
class ChatScrollBar extends StatefulWidget {
final ScrollController controller;
const ChatScrollBar({super.key, required this.controller});
@override
State<ChatScrollBar> createState() => _ChatScrollBarState();
}
class _ChatScrollBarState extends State<ChatScrollBar> {
bool _hovering = false;
bool _scrolling = false;
DateTime _lastScroll = DateTime.fromMillisecondsSinceEpoch(0);
double? _stableThumbHeight;
double? _lastThumbHeight;
double? _lastThumbTop;
bool _lastVisible = false;
@override
void initState() {
@@ -312,13 +268,23 @@ class _FullHeightScrollbarState extends State<FullHeightScrollbar> {
widget.controller.addListener(_onScroll);
}
@override
void didUpdateWidget(covariant ChatScrollBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
oldWidget.controller.removeListener(_onScroll);
widget.controller.addListener(_onScroll);
}
}
void _onScroll() {
_lastScroll = DateTime.now();
setState(() => _scrolling = true);
Future.delayed(const Duration(milliseconds: 800), () {
if (!_scrolling && mounted) {
setState(() => _scrolling = true);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (DateTime.now().difference(_lastScroll).inMilliseconds >= 800) {
if (DateTime.now().difference(_lastScroll).inMilliseconds >= 500 && _scrolling) {
setState(() => _scrolling = false);
}
});
@@ -333,7 +299,6 @@ class _FullHeightScrollbarState extends State<FullHeightScrollbar> {
@override
Widget build(BuildContext context) {
final visible = _hovering || _scrolling;
return MouseRegion(
onEnter: (_) => setState(() => _hovering = true),
onExit: (_) => setState(() => _hovering = false),
@@ -342,38 +307,71 @@ class _FullHeightScrollbarState extends State<FullHeightScrollbar> {
duration: const Duration(milliseconds: 200),
child: LayoutBuilder(
builder: (context, constraints) {
final totalHeight = constraints.maxHeight;
if (!widget.controller.hasClients) return const SizedBox.shrink();
final pos = widget.controller.position;
final maxScroll = pos.maxScrollExtent;
if (maxScroll <= 0) return const SizedBox.shrink();
final viewportFraction = pos.viewportDimension / (pos.viewportDimension + maxScroll);
final thumbHeight = (viewportFraction * totalHeight).clamp(32.0, totalHeight);
final scrollFraction = pos.pixels / maxScroll;
final thumbTop = scrollFraction * (totalHeight - thumbHeight);
final totalHeight = constraints.maxHeight;
final liveThumbHeight = ((pos.viewportDimension / (pos.viewportDimension + maxScroll)) * totalHeight).clamp(32.0, totalHeight);
final thumbHeight = _stableThumbHeight ?? liveThumbHeight;
if (!_scrolling && (_stableThumbHeight == null || (_stableThumbHeight! - liveThumbHeight).abs() > 2)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (_stableThumbHeight != liveThumbHeight) {
setState(() => _stableThumbHeight = liveThumbHeight);
}
});
}
final thumbTop = (pos.pixels / maxScroll) * (totalHeight - thumbHeight);
if (_lastThumbHeight != thumbHeight || _lastThumbTop != thumbTop || _lastVisible != visible) {
_lastThumbHeight = thumbHeight;
_lastThumbTop = thumbTop;
_lastVisible = visible;
}
final color = Theme.of(context).colorScheme.mutedForeground.withValues(alpha: 0.4);
return Stack(
children: [
Positioned(
top: thumbTop,
left: 2,
right: 2,
height: thumbHeight,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(4),
return GestureDetector(
behavior: HitTestBehavior.translucent,
onVerticalDragStart: (details) {
final trackTravel = (totalHeight - thumbHeight).clamp(1.0, double.infinity);
final nextThumbTop = (details.localPosition.dy - thumbHeight / 2).clamp(0.0, trackTravel);
final nextPixels = (nextThumbTop / trackTravel) * maxScroll;
widget.controller.jumpTo(nextPixels.clamp(0.0, maxScroll));
},
onVerticalDragUpdate: (details) {
final trackTravel = (totalHeight - thumbHeight).clamp(1.0, double.infinity);
final nextThumbTop = (details.localPosition.dy - thumbHeight / 2).clamp(0.0, trackTravel);
final nextPixels = (nextThumbTop / trackTravel) * maxScroll;
widget.controller.jumpTo(nextPixels.clamp(0.0, maxScroll));
},
onTapDown: (details) {
final trackTravel = (totalHeight - thumbHeight).clamp(1.0, double.infinity);
final centerTop = (details.localPosition.dy - thumbHeight / 2).clamp(0.0, trackTravel);
final nextPixels = (centerTop / trackTravel) * maxScroll;
widget.controller.jumpTo(nextPixels.clamp(0.0, maxScroll));
},
child: Stack(
children: [
Positioned.fill(
child: const SizedBox.shrink(),
),
Positioned(
top: thumbTop,
left: 0,
right: 0,
height: thumbHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.mutedForeground.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(4),
),
),
),
),
),
],
],
),
);
},
),
+51 -15
View File
@@ -6,6 +6,9 @@ import "../../../src/permissions/permission_types.dart";
import "../../../src/session/session_types.dart";
import "advisor_message.dart";
import "../common/button.dart";
import "dart:typed_data";
import "attachment_preview.dart";
import "../../models/attachment.dart";
class MessageBubble extends StatelessWidget {
const MessageBubble({
@@ -28,20 +31,53 @@ class MessageBubble extends StatelessWidget {
if (isUser) {
final atts = message.attachments;
return Align(
alignment: Alignment.centerRight,
child: OutlinedContainer(
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
backgroundColor: theme.colorScheme.border,
child: MarkdownBody(
data: message.content,
selectable: true,
shrinkWrap: true,
styleSheet: _toolMarkdownStyleSheet(context),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (atts != null && atts.isNotEmpty) ...[
SingleChildScrollView(
scrollDirection: Axis.horizontal,
reverse: true,
child: Row(
children: [
for (final att in atts)
Padding(
padding: EdgeInsets.only(left: 8),
child: AttachmentItem(
attachment: Attachment(
name: att.name,
mimeType: att.mimeType,
data: Uint8List.fromList(att.data),
),
onRemove: () {},
),
),
],
),
),
Gap(6),
],
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) {
@@ -94,21 +130,21 @@ class MessageBubble extends StatelessWidget {
AgcSecondaryButton(
onPressed: () => onPermissionDecision?.call(PermissionDecision.allowOnce),
child: Text("Allow").small,
child: Text("Yes").small,
),
Gap(8),
AgcGhostButton(
onPressed: () => onPermissionDecision?.call(PermissionDecision.allowAlways),
child: Text("Allow always").small,
child: Text("Yes, for this session").small,
),
Gap(8),
AgcGhostButton(
onPressed: () => onPermissionDecision?.call(PermissionDecision.reject),
child: Text("Reject").small,
child: Text("No").small,
),
],
+205
View File
@@ -0,0 +1,205 @@
import "package:flutter/widgets.dart";
// Renders text via TextPainter directly, bypassing any theme/font overrides
// from shadcn or other inherited themes. Use this when you need a specific
// font (e.g. google fonts) and the theme keeps clobbering it.
class AnaText extends StatefulWidget {
const AnaText(
this.text, {
required this.style,
this.textAlign = TextAlign.left,
this.maxLines,
super.key,
});
final String text;
final TextStyle style;
final TextAlign textAlign;
final int? maxLines;
@override
State<AnaText> createState() => _AnaTextState();
}
class _AnaTextState extends State<AnaText> {
int _fontVersion = 0;
@override
void initState() {
super.initState();
PaintingBinding.instance.systemFonts.addListener(_onFontChange);
}
void _onFontChange() {
setState(() => _fontVersion++);
}
@override
void dispose() {
PaintingBinding.instance.systemFonts.removeListener(_onFontChange);
super.dispose();
}
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _AnaTextPainter(
text: widget.text,
style: widget.style,
textAlign: widget.textAlign,
maxLines: widget.maxLines,
textDirection: Directionality.of(context),
fontVersion: _fontVersion,
),
child: _AnaTextSizer(
text: widget.text,
style: widget.style,
maxLines: widget.maxLines,
textDirection: Directionality.of(context),
fontVersion: _fontVersion,
),
);
}
}
class _AnaTextPainter extends CustomPainter {
_AnaTextPainter({
required this.text,
required this.style,
required this.textAlign,
required this.textDirection,
required this.fontVersion,
this.maxLines,
});
final String text;
final TextStyle style;
final TextAlign textAlign;
final TextDirection textDirection;
final int? maxLines;
final int fontVersion;
@override
void paint(Canvas canvas, Size size) {
final tp = TextPainter(
text: TextSpan(text: text, style: style),
textAlign: textAlign,
textDirection: textDirection,
maxLines: maxLines,
)..layout(maxWidth: size.width);
final dy = (size.height - tp.height) / 2;
tp.paint(canvas, Offset(0, dy.clamp(0.0, double.infinity)));
}
@override
bool shouldRepaint(_AnaTextPainter old) =>
old.text != text ||
old.style != style ||
old.textAlign != textAlign ||
old.maxLines != maxLines ||
old.fontVersion != fontVersion;
}
// Invisible child that reports the natural text size back to the layout system
// so CustomPaint gets constrained correctly.
class _AnaTextSizer extends LeafRenderObjectWidget {
const _AnaTextSizer({
required this.text,
required this.style,
required this.textDirection,
required this.fontVersion,
this.maxLines,
});
final String text;
final TextStyle style;
final TextDirection textDirection;
final int? maxLines;
final int fontVersion;
@override
RenderObject createRenderObject(BuildContext context) => _AnaTextSizerBox(
text: text,
style: style,
textDirection: textDirection,
maxLines: maxLines,
fontVersion: fontVersion,
);
@override
void updateRenderObject(BuildContext context, _AnaTextSizerBox renderObject) {
renderObject
..text = text
..style = style
..textDirection = textDirection
..maxLines = maxLines
..fontVersion = fontVersion;
}
}
class _AnaTextSizerBox extends RenderBox {
_AnaTextSizerBox({
required String text,
required TextStyle style,
required TextDirection textDirection,
required int fontVersion,
int? maxLines,
}) : _text = text,
_style = style,
_textDirection = textDirection,
_maxLines = maxLines,
_fontVersion = fontVersion;
String _text;
TextStyle _style;
TextDirection _textDirection;
int? _maxLines;
int _fontVersion;
set text(String v) { if (_text == v) return; _text = v; markNeedsLayout(); }
set style(TextStyle v) { if (_style == v) return; _style = v; markNeedsLayout(); }
set textDirection(TextDirection v) {
if (_textDirection == v) return;
_textDirection = v;
markNeedsLayout();
}
set maxLines(int? v) { if (_maxLines == v) return; _maxLines = v; markNeedsLayout(); }
set fontVersion(int v) { if (_fontVersion == v) return; _fontVersion = v; markNeedsLayout(); }
TextPainter _buildPainter({double maxWidth = double.infinity}) {
return TextPainter(
text: TextSpan(text: _text, style: _style),
textDirection: _textDirection,
maxLines: _maxLines,
)..layout(maxWidth: maxWidth);
}
@override
double computeMinIntrinsicWidth(double height) => _buildPainter().width;
@override
double computeMaxIntrinsicWidth(double height) => _buildPainter().width;
@override
double computeMinIntrinsicHeight(double width) =>
_buildPainter(maxWidth: width).height;
@override
double computeMaxIntrinsicHeight(double width) =>
_buildPainter(maxWidth: width).height;
@override
void performLayout() {
final tp = _buildPainter();
size = constraints.constrain(Size(tp.width, tp.height));
}
@override
void paint(PaintingContext context, Offset offset) {}
}
+118
View File
@@ -0,0 +1,118 @@
import "dart:io";
import "package:flutter/foundation.dart";
import "package:google_fonts/google_fonts.dart";
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "ana_text.dart";
import "../../../src/project_store.dart";
import "../../providers/home_coordinator.dart";
import "../../providers/projects_provider.dart";
class AppHeader extends StatelessWidget {
const AppHeader({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final coordinator = context.read<HomeCoordinator>();
final selectedProject = context.watch<ProjectsProvider>().selectedProject;
final isWindows = !kIsWeb && defaultTargetPlatform == TargetPlatform.windows;
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: theme.colorScheme.background,
border: Border(
bottom: BorderSide(color: theme.colorScheme.border, width: 1),
),
),
child: IntrinsicHeight(
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(left: 12, top: 4, bottom: 4),
child: _AgencyMenuBar(coordinator: coordinator),
),
const Spacer(),
_ProjectNameBox(project: selectedProject),
if (isWindows)
const Gap(6)
else
const Gap(64),
],
),
),
);
}
}
class _AgencyMenuBar extends StatelessWidget {
final HomeCoordinator coordinator;
const _AgencyMenuBar({required this.coordinator});
@override
Widget build(BuildContext context) {
return Menubar(
border: false,
children: [
MenuButton(
subMenu: [
MenuButton(
leading: const Icon(LucideIcons.squarePen).iconSmall,
onPressed: (_) => coordinator.createNewChat(),
child: const Text("New Chat"),
),
MenuButton(
leading: const Icon(LucideIcons.folderPlus).iconSmall,
onPressed: (_) => coordinator.pickProjectDirectory(),
child: const Text("New Project"),
),
],
child: const Text("File"),
),
],
);
}
}
class _ProjectNameBox extends StatelessWidget {
final ProjectRecord? project;
const _ProjectNameBox({required this.project});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final label = project?.name ?? "No project";
final textStyle = GoogleFonts.ibmPlexMono(
fontSize: 11,
height: 1,
fontWeight: FontWeight.w600,
color: theme.colorScheme.mutedForeground,
);
return Container(
color: theme.colorScheme.border,
constraints: const BoxConstraints(minWidth: 100),
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 10),
child: AnaText(label, style: textStyle),
);
}
}
+9 -7
View File
@@ -36,21 +36,23 @@ class _GhostButtonState extends State<AgcGhostButton> {
bg = colorScheme.accent.withOpacity(0.5);
}
final active = widget.onPressed != null;
return MouseRegion(
cursor: widget.onPressed != null ? SystemMouseCursors.click : MouseCursor.defer,
onEnter: (_) => setState(() => _hovering = true),
cursor: active ? SystemMouseCursors.click : MouseCursor.defer,
onEnter: (_) { if (active) setState(() => _hovering = true); },
onExit: (_) => setState(() {
_hovering = false;
_pressing = false;
}),
child: GestureDetector(
onTapDown: (_) => setState(() => _pressing = true),
onTapUp: (_) {
onTapDown: active ? (_) => setState(() => _pressing = true) : null,
onTapUp: active ? (_) {
setState(() => _pressing = false);
if (widget.onPressed != null) widget.onPressed!();
},
onTapCancel: () => setState(() => _pressing = false),
widget.onPressed!();
} : null,
onTapCancel: active ? () => setState(() => _pressing = false) : null,
child: AnimatedContainer(
duration: Duration(milliseconds: 80),
+69 -24
View File
@@ -1,10 +1,12 @@
import "package:flutter/widgets.dart" hide Tooltip;
import "package:google_fonts/google_fonts.dart";
import "package:shadcn_flutter/shadcn_flutter.dart" hide Row, Expanded;
import "package:provider/provider.dart";
import "../../providers/chat_provider.dart";
import "../../providers/cost_provider.dart";
import "../../providers/settings_provider.dart";
import "package:provider/provider.dart";
import "ana_text.dart";
@@ -27,7 +29,7 @@ class FooterBar extends StatelessWidget {
final theme = Theme.of(context);
final mutedFg = theme.colorScheme.mutedForeground;
final borderColor = theme.colorScheme.border;
final bg = theme.colorScheme.muted.scaleAlpha(0.3);
final bg = theme.colorScheme.background;
final costProvider = context.watch<CostProvider>();
final settingsProvider = context.watch<SettingsProvider>();
@@ -39,10 +41,11 @@ class FooterBar extends StatelessWidget {
final inputToks = costProvider.getTotalInputTokens();
final outputToks = costProvider.getTotalOutputTokens();
final isLoading = chatProvider.isLoading;
final isCompacting = chatProvider.isCompacting;
final contextTokens = chatProvider.contextTokens;
final warningState = chatProvider.tokenWarningState;
final textStyle = TextStyle(
fontFamily: "monospace",
final textStyle = GoogleFonts.ibmPlexMono(
fontSize: 11,
height: 1,
fontWeight: FontWeight.w600,
@@ -55,61 +58,103 @@ class FooterBar extends StatelessWidget {
);
Widget copyrightBlock() {
return Text(
return AnaText(
"© 2026 IMBENJI.NET LTD - The Agency",
style: textStyle,
);
}
Widget statusBlock() {
final label = isCompacting
? "compacting..."
: isLoading
? "running..."
: "idle";
final labelColor = isCompacting
? theme.colorScheme.primary.withAlpha(180)
: isLoading
? theme.colorScheme.primary
: mutedFg;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
isLoading ? "running..." : "idle",
style: textStyle.copyWith(
color: isLoading
? theme.colorScheme.primary
: mutedFg,
),
),
AnaText(label, style: textStyle.copyWith(color: labelColor)),
divider(),
Text(model.split("/").last, style: textStyle),
AnaText(model.split("/").last, style: textStyle),
],
);
}
Color? _contextWarningColor() {
if (warningState == null) return null;
if (warningState.isAboveErrorThreshold) return const Color(0xFFEF4444); // red
if (warningState.isAboveWarningThreshold) return const Color(0xFFF59E0B); // amber
return null;
}
Widget statsBlock() {
final warnColor = _contextWarningColor();
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// compact button — shown when we're near the threshold
if (warningState != null && warningState.isAboveWarningThreshold && !isLoading && !isCompacting) ...[
GestureDetector(
onTap: () => chatProvider.runCompact(),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: AnaText(
"compact",
style: textStyle.copyWith(
color: warnColor ?? mutedFg,
decoration: TextDecoration.underline,
decorationColor: warnColor ?? mutedFg,
),
),
),
),
divider(),
],
if (contextTokens > 0) ...[
Text(_fmtTokens(contextTokens), style: textStyle),
Text(" tokens", style: textStyle),
Tooltip(
tooltip: (_) => TooltipContainer(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: AnaText(
warningState != null
? "${warningState.percentLeft}% context remaining"
: _fmtTokens(contextTokens),
style: GoogleFonts.ibmPlexMono(fontSize: 11, height: 1.2),
),
),
child: AnaText(
_fmtTokens(contextTokens),
style: textStyle.copyWith(color: warnColor ?? mutedFg),
),
),
AnaText(" tokens", style: textStyle.copyWith(color: warnColor ?? mutedFg)),
divider(),
],
Tooltip(
tooltip: (_) => TooltipContainer(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Text(
child: AnaText(
"In: $inputToks\nOut: $outputToks",
style: const TextStyle(
fontFamily: "monospace",
fontSize: 11,
height: 1.2,
),
style: GoogleFonts.ibmPlexMono(fontSize: 11, height: 1.2),
),
),
child: Text(cost, style: textStyle),
child: AnaText(cost, style: textStyle),
),
],
);
}
+67 -19
View File
@@ -1,31 +1,79 @@
import "dart:io";
import "package:shadcn_flutter/shadcn_flutter.dart";
class AppHeader extends StatelessWidget {
const AppHeader({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"THE AGENCY",
style: TextStyle(
fontSize: 32,
height: 1,
fontWeight: FontWeight.w900,
),
final theme = Theme.of(context);
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: theme.colorScheme.background,
border: Border(
bottom: BorderSide(color: theme.colorScheme.border, width: 1),
),
Text(
"by IMBENJI.NET LTD",
style: TextStyle(
fontSize: 12,
height: 1,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.mutedForeground,
),
),
child: IntrinsicHeight(
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(left: 16, top: 8, bottom: 8),
child: _Logo(),
),
const Spacer(),
SizedBox(width: Platform.isMacOS ? 64 : 12),
],
),
],
),
);
}
}
class _Logo extends StatelessWidget {
const _Logo();
@override
Widget build(BuildContext context) {
final muted = Theme.of(context).colorScheme.mutedForeground;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
"THE AGENCY",
style: TextStyle(
fontFamily: "monospace",
fontSize: 13,
height: 1,
fontWeight: FontWeight.w800,
letterSpacing: 1.5,
),
),
Text(
"by IMBENJI.NET LTD",
style: TextStyle(
fontFamily: "monospace",
fontSize: 10,
height: 1.4,
fontWeight: FontWeight.w600,
color: muted,
),
),
],
);
}
}
+15 -3
View File
@@ -52,7 +52,7 @@ class Sidebar extends StatelessWidget {
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: _projectsSection(context, projectsProvider, sessionProvider, coordinator),
child: _projectsSection(context, projectsProvider, sessionProvider, coordinator, chatProvider),
),
),
@@ -109,7 +109,7 @@ class Sidebar extends StatelessWidget {
);
}
Widget _projectsSection(BuildContext context, ProjectsProvider projectsProvider, SessionProvider sessionProvider, HomeCoordinator coordinator) {
Widget _projectsSection(BuildContext context, ProjectsProvider projectsProvider, SessionProvider sessionProvider, HomeCoordinator coordinator, ChatProvider chatProvider) {
if (projectsProvider.projects.isEmpty) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
@@ -131,6 +131,17 @@ class Sidebar extends StatelessWidget {
..sort((a, b) => b.updated.compareTo(a.updated));
});
final projects = List.of(projectsProvider.projects)
..sort((a, b) {
final aLatest = sorted[a.workingDirectory]?.firstOrNull?.updated;
final bLatest = sorted[b.workingDirectory]?.firstOrNull?.updated;
if (aLatest == null && bLatest == null) return 0;
if (aLatest == null) return 1;
if (bLatest == null) return -1;
return bLatest.compareTo(aLatest);
});
return ListView(
padding: EdgeInsets.zero,
children: [
@@ -142,7 +153,7 @@ class Sidebar extends StatelessWidget {
Gap(8),
for (final project in projectsProvider.projects) ...[
for (final project in projects) ...[
ProjectSection(
projectLabel: project.name,
@@ -158,6 +169,7 @@ class Sidebar extends StatelessWidget {
label: session.name,
lastMessage: session.updated,
selected: sessionProvider.currentSessionId == session.id,
isRunning: chatProvider.isSessionRunning(session.id),
onPressed: () => coordinator.openSession(session),
onDelete: () => coordinator.deleteSession(session),
),
+438
View File
@@ -0,0 +1,438 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../src/session/session_types.dart";
import "../../providers/chat_provider.dart";
import "../../providers/home_coordinator.dart";
import "../../providers/projects_provider.dart";
import "../../providers/session_provider.dart";
import "../../utils/format_relative_time.dart";
import "app_logo.dart";
import "../common/button.dart";
class SidebarV2 extends StatelessWidget {
const SidebarV2({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: 300,
color: theme.colorScheme.input.scaleAlpha(0.3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 28),
child: AppLogo(),
),
// Divider(color: theme.colorScheme.border, height: 1),
_ActionsSection(),
Divider(color: theme.colorScheme.border, height: 1),
Expanded(child: _ProjectsSection()),
],
),
);
}
}
class _ActionsSection extends StatelessWidget {
const _ActionsSection();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final coordinator = context.read<HomeCoordinator>();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// _SectionHeader(title: "Actions"),
Divider(color: theme.colorScheme.border, height: 1),
_PanelItem(
icon: LucideIcons.squarePen,
label: "New Chat",
onTap: coordinator.createNewChat,
),
Divider(color: theme.colorScheme.border, height: 1),
_PanelItem(
icon: LucideIcons.folderPlus,
label: "New Project",
onTap: coordinator.pickProjectDirectory,
),
Divider(color: theme.colorScheme.border, height: 1),
],
);
}
}
class _ProjectsSection extends StatelessWidget {
const _ProjectsSection();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final projectsProvider = context.watch<ProjectsProvider>();
final sessionProvider = context.watch<SessionProvider>();
final chatProvider = context.watch<ChatProvider>();
final coordinator = context.read<HomeCoordinator>();
if (projectsProvider.projects.isEmpty) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_SectionHeader(title: "Projects"),
Divider(color: theme.colorScheme.border, height: 1),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Text(
"No projects yet",
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.mutedForeground,
),
),
),
],
);
}
// group sessions by working directory, sorted newest first
final sessionsByDir = <String, List<SessionSummary>>{};
for (final s in sessionProvider.sessions) {
final dir = s.workingDirectory ?? "";
sessionsByDir.putIfAbsent(dir, () => []).add(s);
}
sessionsByDir.forEach((_, list) {
list.sort((a, b) => b.updated.compareTo(a.updated));
});
final projects = List.of(projectsProvider.projects)
..sort((a, b) {
final aLatest = sessionsByDir[a.workingDirectory]?.firstOrNull?.updated;
final bLatest = sessionsByDir[b.workingDirectory]?.firstOrNull?.updated;
if (aLatest == null && bLatest == null) return 0;
if (aLatest == null) return 1;
if (bLatest == null) return -1;
return bLatest.compareTo(aLatest);
});
return ListView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.zero,
children: [
for (final project in projects) ...[
_CollapsibleProjectSection(
projectName: project.name,
sessions: sessionsByDir[project.workingDirectory] ?? [],
currentSessionId: sessionProvider.currentSessionId,
isSessionRunning: chatProvider.isSessionRunning,
sessionNeedsAttention: chatProvider.sessionNeedsAttention,
sessionHasUnreadResult: chatProvider.sessionHasUnreadResult,
onOpenSession: coordinator.openSession,
onDeleteSession: coordinator.deleteSession,
),
Divider(color: theme.colorScheme.border, height: 1),
],
],
);
}
}
class _CollapsibleProjectSection extends StatefulWidget {
final String projectName;
final List<SessionSummary> sessions;
final String? currentSessionId;
final bool Function(String sessionId) isSessionRunning;
final bool Function(String sessionId) sessionNeedsAttention;
final bool Function(String sessionId) sessionHasUnreadResult;
final void Function(SessionSummary) onOpenSession;
final void Function(SessionSummary) onDeleteSession;
const _CollapsibleProjectSection({
required this.projectName,
required this.sessions,
required this.currentSessionId,
required this.isSessionRunning,
required this.sessionNeedsAttention,
required this.sessionHasUnreadResult,
required this.onOpenSession,
required this.onDeleteSession,
});
@override
State<_CollapsibleProjectSection> createState() =>
_CollapsibleProjectSectionState();
}
class _CollapsibleProjectSectionState
extends State<_CollapsibleProjectSection> {
bool _expanded = true;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
behavior: HitTestBehavior.opaque,
child: ColoredBox(
color: theme.colorScheme.secondary,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
AnimatedRotation(
turns: _expanded ? 0.0 : -0.25,
duration: const Duration(milliseconds: 120),
child: Icon(
LucideIcons.chevronDown,
size: 12,
color: theme.colorScheme.mutedForeground,
),
),
const SizedBox(width: 4),
Text(
widget.projectName,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.foreground,
letterSpacing: 0.4,
),
),
],
),
),
),
),
Divider(color: theme.colorScheme.border, height: 1),
if (_expanded) ...[
if (widget.sessions.isEmpty)
_PanelItem(
icon: LucideIcons.frown,
label: "No sessions yet",
onTap: null,
)
else
for (int i = 0; i < widget.sessions.length; i++) ...[
_ThreadItem(
session: widget.sessions[i],
selected: widget.currentSessionId == widget.sessions[i].id,
isRunning: widget.isSessionRunning(widget.sessions[i].id),
needsAttention: widget.sessionNeedsAttention(widget.sessions[i].id),
hasUnreadResult: widget.sessionHasUnreadResult(widget.sessions[i].id),
onTap: () => widget.onOpenSession(widget.sessions[i]),
onDelete: () => widget.onDeleteSession(widget.sessions[i]),
),
if (i < widget.sessions.length - 1)
Divider(color: theme.colorScheme.border, height: 1),
],
] else ...[
Divider(color: theme.colorScheme.background,)
]
],
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ColoredBox(
color: theme.colorScheme.secondary,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Text(
title,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.foreground,
letterSpacing: 0.4,
),
),
),
);
}
}
class _PanelItem extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback? onTap;
const _PanelItem({required this.icon, required this.label, this.onTap});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final muted = onTap == null;
return Padding(
padding: const EdgeInsets.all(1),
child: AgcGhostButton(
onPressed: onTap,
borderRadius: BorderRadius.zero,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
child: Row(
children: [
Icon(
icon,
color: muted
? theme.colorScheme.mutedForeground
: theme.colorScheme.foreground,
).iconSmall,
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
color: muted
? theme.colorScheme.mutedForeground
: theme.colorScheme.foreground,
),
).small,
],
),
),
),
);
}
}
class _ThreadItem extends StatelessWidget {
final SessionSummary session;
final bool selected;
final bool isRunning;
final bool needsAttention;
final bool hasUnreadResult;
final VoidCallback onTap;
final VoidCallback onDelete;
const _ThreadItem({
required this.session,
required this.selected,
this.isRunning = false,
this.needsAttention = false,
this.hasUnreadResult = false,
required this.onTap,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const amber = Color(0xFFF59E0B);
const green = Color(0xFF22C55E);
Color? glowColor;
if (needsAttention) {
glowColor = amber;
} else if (hasUnreadResult) {
glowColor = green;
}
Widget trailingWidget;
if (needsAttention) {
trailingWidget = Icon(LucideIcons.triangleAlert, size: 13, color: amber);
} else if (isRunning) {
trailingWidget = SizedBox(
width: 12, height: 12,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: theme.colorScheme.primary,
),
);
} else if (hasUnreadResult) {
trailingWidget = Icon(LucideIcons.circleCheck, size: 13, color: green);
} else {
trailingWidget = Text(
formatRelativeTime(session.updated),
style: TextStyle(color: theme.colorScheme.mutedForeground),
).muted.xSmall.light;
}
return ContextMenu(
items: [
MenuButton(
leading: const Icon(LucideIcons.trash2).iconSmall,
onPressed: (_) => onDelete(),
child: const Text("Delete"),
),
],
child: Container(
margin: EdgeInsets.all(1),
decoration: BoxDecoration(
color: glowColor != null
? glowColor.withAlpha(selected ? 55 : 30)
: selected ? theme.colorScheme.accent : Colors.transparent,
border: glowColor != null
? Border(left: BorderSide(color: glowColor, width: 2))
: null,
),
child: AgcGhostButton(
onPressed: onTap,
borderRadius: BorderRadius.zero,
child: Padding(
padding: EdgeInsets.only(
left: glowColor != null ? 22 : 24,
right: 12,
top: 8,
bottom: 8,
),
child: Row(
children: [
Expanded(
child: Text(
session.name,
style: TextStyle(
color: selected
? theme.colorScheme.accentForeground
: theme.colorScheme.foreground,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
).small,
),
Gap(8),
trailingWidget,
],
),
),
),
),
);
}
}
+14 -4
View File
@@ -10,6 +10,7 @@ class ThreadButton extends StatelessWidget {
final DateTime? lastMessage;
final bool selected;
final bool muted;
final bool isRunning;
ThreadButton({
required this.label,
@@ -19,6 +20,7 @@ class ThreadButton extends StatelessWidget {
this.lastMessage,
this.selected = false,
this.muted = false,
this.isRunning = false,
});
@override
@@ -50,10 +52,18 @@ class ThreadButton extends StatelessWidget {
).iconSmall,
trailingGap: 32,
trailing: lastMessage != null ?
Text(
formatRelativeTime(lastMessage!)
).muted.small.light : null,
trailing: isRunning
? SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: colorScheme.primary,
),
)
: lastMessage != null
? Text(formatRelativeTime(lastMessage!)).muted.small.light
: null,
child: Text(
label,
style: TextStyle(