Add new features and update configurations for improved functionality

This commit is contained in:
ImBenji
2026-04-11 12:34:00 +01:00
parent fa4415553d
commit 0b6b604c56
125 changed files with 14119 additions and 1664 deletions
+10 -4
View File
@@ -1,8 +1,9 @@
import "package:clawd_code/ui/screens/new_home_screen.dart";
import "package:go_router/go_router.dart";
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "providers/settings_provider.dart";
import "routes/router.dart";
class ClawdApp extends StatelessWidget {
const ClawdApp();
@@ -11,10 +12,15 @@ class ClawdApp extends StatelessWidget {
Widget build(BuildContext context) {
return Consumer<SettingsProvider>(
builder: (context, settingsProvider, _) {
return ShadcnApp(
return ShadcnApp.router(
title: "Clawd",
home: NewHomeScreen(),
theme: ThemeData(colorScheme: ColorSchemes.darkNeutral, radius: 0.5),
routerConfig: AppRouter.router,
scaling: const AdaptiveScaling(0.9),
theme: ThemeData(
colorScheme: ColorSchemes.darkGray.rose,
density: Density.spaciousDensity,
radius: 0.5,
),
);
},
);
+20 -33
View File
@@ -36,40 +36,27 @@ const List<SelectableAiModel> selectableAiModels = [
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",
),
SelectableAiModel(
group: "Recommended",
id: "google/gemma-4-31b-it",
label: "Gemma 4 31B IT",
),
SelectableAiModel(
group: "Recommended",
id: "qwen/qwen3.6-plus",
label: "Qwen3.6 Plus",
),
SelectableAiModel(
group: "Recommended",
id: "anthropic/claude-sonnet-4.6",
label: "Claude Sonnet 4.6",
)
// SelectableAiModel(
// group: "Anthropic",
// id: "anthropic/claude-sonnet-4.6",
// label: "Claude Sonnet 4.6",
// ),
// SelectableAiModel(
// group: "Anthropic",
// id: "anthropic/claude-opus-4.6",
// label: "Claude Opus 4.6",
// ),
// SelectableAiModel(
// group: "Anthropic",
// id: "anthropic/claude-haiku-4.5",
// label: "Claude Haiku 4.5",
// ),
// SelectableAiModel(group: "OpenAI", id: "openai/gpt-5.4", label: "GPT-5.4"),
// SelectableAiModel(
// group: "OpenAI",
// id: "openai/gpt-5.4-mini",
// label: "GPT-5.4 Mini",
// ),
// SelectableAiModel(group: "OpenAI", id: "openai/gpt-4.1", label: "GPT-4.1"),
// SelectableAiModel(group: "Qwen", id: "qwen/qwen3.5-9b", label: "Qwen3.5-9B"),
// SelectableAiModel(
// group: "Qwen",
// id: "qwen/qwen3.5-35b-a3b",
// label: "Qwen3.5-35B-A3B",
// ),
// SelectableAiModel(
// group: "Qwen",
// id: "qwen/qwen3.5-flash-02-23",
// label: "Qwen3.5-Flash",
// ),
];
+22
View File
@@ -0,0 +1,22 @@
import 'dart:typed_data';
class Attachment {
final String name;
final String mimeType;
final Uint8List data;
final DateTime createdAt;
Attachment({
required this.name,
required this.mimeType,
required this.data,
DateTime? createdAt,
}) : createdAt = createdAt ?? DateTime.now();
bool get isImage => mimeType.startsWith('image/');
bool get isPdf => mimeType == 'application/pdf';
bool get isText => mimeType.startsWith('text/');
int get sizeInKB => (data.length / 1024).ceil();
String get displayName => name;
}
+150 -612
View File
@@ -1,18 +1,16 @@
import "package:file_picker/file_picker.dart";
import "package:go_router/go_router.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/home_coordinator.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 "../../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/sidebar/sidebar.dart";
class NewHomeScreen extends StatefulWidget {
const NewHomeScreen({super.key});
@@ -22,202 +20,34 @@ class NewHomeScreen extends StatefulWidget {
}
class _NewHomeScreenState extends State<NewHomeScreen> {
late final TextEditingController _messageController;
final ScrollController _chatScrollController = ScrollController();
@override
void initState() {
super.initState();
_messageController = TextEditingController();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<HomeCoordinator>().addListener(_onCoordinatorChanged);
});
}
@override
void dispose() {
_messageController.dispose();
context.read<HomeCoordinator>().removeListener(_onCoordinatorChanged);
_chatScrollController.dispose();
super.dispose();
}
Iterable<MapEntry<String, List<String>>> _filteredModels(String searchQuery) {
final normalizedQuery = searchQuery.trim().toLowerCase();
if (normalizedQuery.isEmpty) {
return _modelGroups.entries;
}
return _modelGroups.entries
.map((entry) {
final matchingModels = entry.value
.where(
(modelId) =>
modelId.toLowerCase().contains(normalizedQuery) ||
_modelLabel(
modelId,
).toLowerCase().contains(normalizedQuery),
)
.toList();
return MapEntry(entry.key, matchingModels);
})
.where((entry) => entry.value.isNotEmpty);
}
Map<String, List<String>> get _modelGroups {
final groups = <String, List<String>>{};
for (final model in selectableAiModels) {
groups.putIfAbsent(model.group, () => <String>[]).add(model.id);
}
return groups;
}
String _modelLabel(String modelId) {
for (final model in selectableAiModels) {
if (model.id == modelId) {
return model.label;
}
}
return modelId;
}
Future<void> _pickProjectDirectory() async {
try {
final selectedDirectory = await FilePicker.platform.getDirectoryPath(
dialogTitle: "Select project directory",
);
if (selectedDirectory == null || !mounted) {
return;
}
final projectsProvider = context.read<ProjectsProvider>();
final sessionProvider = context.read<SessionProvider>();
final chatProvider = context.read<ChatProvider>();
final project = await projectsProvider.addProject(selectedDirectory);
if (project == null && mounted) {
await _showProjectPickerError(
"The selected folder could not be added as a project.",
);
return;
}
projectsProvider.selectProject(project!.id);
sessionProvider.clearCurrentSession(
workingDirectory: project.workingDirectory,
);
chatProvider.clearConversation();
} catch (error, stackTrace) {
print("Project directory picker failed: $error");
print(stackTrace);
if (!mounted) {
return;
}
await _showProjectPickerError(error.toString());
void _onCoordinatorChanged() {
final coordinator = context.read<HomeCoordinator>();
final err = coordinator.error;
if (err != null) {
coordinator.clearError();
_showError(err);
}
}
Future<void> _createNewChat() async {
final projectsProvider = context.read<ProjectsProvider>();
final selectedProject = projectsProvider.selectedProject;
if (selectedProject == null) {
await _showProjectPickerError(
"Choose a project first so the new chat has a working directory.",
);
return;
}
final sessionProvider = context.read<SessionProvider>();
final chatProvider = context.read<ChatProvider>();
await sessionProvider.createNewSession(
workingDirectory: selectedProject.workingDirectory,
name: "New Chat",
);
chatProvider.setConversation(sessionProvider.getConversationHistory());
}
Future<void> _selectProject(ProjectRecord project) async {
final projectsProvider = context.read<ProjectsProvider>();
final sessionProvider = context.read<SessionProvider>();
final chatProvider = context.read<ChatProvider>();
projectsProvider.selectProject(project.id);
if (sessionProvider.currentSession?.workingDirectory ==
project.workingDirectory) {
return;
}
sessionProvider.clearCurrentSession(
workingDirectory: project.workingDirectory,
);
chatProvider.clearConversation();
}
Future<void> _openSession(SessionSummary session) async {
final sessionProvider = context.read<SessionProvider>();
final chatProvider = context.read<ChatProvider>();
final projectsProvider = context.read<ProjectsProvider>();
await sessionProvider.loadSession(session.id);
chatProvider.setConversation(sessionProvider.getConversationHistory());
projectsProvider.selectProjectByWorkingDirectory(
sessionProvider.activeWorkingDirectory,
);
}
Future<void> _sendMessage() async {
final text = _messageController.text.trim();
if (text.isEmpty) {
return;
}
final sessionProvider = context.read<SessionProvider>();
final projectsProvider = context.read<ProjectsProvider>();
final chatProvider = context.read<ChatProvider>();
final selectedProject = projectsProvider.selectedProject;
if (sessionProvider.currentSession == null) {
if (selectedProject == null) {
await _showProjectPickerError("Pick a project before starting a chat.");
return;
}
await sessionProvider.createNewSession(
workingDirectory: selectedProject.workingDirectory,
name: "New Chat",
);
chatProvider.setConversation(sessionProvider.getConversationHistory());
}
_messageController.clear();
try {
await chatProvider.sendMessage(text);
if (!mounted) {
return;
}
} catch (error, stackTrace) {
print("Failed to send message from home screen: $error");
print(stackTrace);
if (!mounted) {
return;
}
await _showProjectPickerError(error.toString());
} finally {
if (!mounted) {
return;
}
await context.read<SessionProvider>().refreshSessions();
}
}
void _stopMessage() {
context.read<ChatProvider>().stopGenerating();
}
void _openSettings() {
showDialog<void>(
context: context,
builder: (_) => const AlertDialog(content: SettingsSheet()),
);
}
Future<void> _showProjectPickerError(String message) {
Future<void> _showError(String message) {
return showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
@@ -235,439 +65,78 @@ class _NewHomeScreenState extends State<NewHomeScreen> {
@override
Widget build(BuildContext context) {
final projectsProvider = context.watch<ProjectsProvider>();
final sessionProvider = context.watch<SessionProvider>();
final chatProvider = context.watch<ChatProvider>();
final settingsProvider = context.watch<SettingsProvider>();
final costProvider = context.watch<CostProvider>();
// Group sessions by working directory
final sessionsByProject = <String, List<SessionSummary>>{};
for (final session in sessionProvider.sessions) {
final workingDirectory = session.workingDirectory ?? '';
if (!sessionsByProject.containsKey(workingDirectory)) {
sessionsByProject[workingDirectory] = <SessionSummary>[];
}
sessionsByProject[workingDirectory]!.add(session);
}
final selectedProject = projectsProvider.selectedProject;
final selectedWorkingDirectory = selectedProject?.workingDirectory;
final currentModel = settingsProvider.normalizeModelId(
settingsProvider.settings.model,
);
return Scaffold(
child: Row(
child: Column(
children: [
SizedBox(
width: 320,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
Expanded(
child: Row(
children: [
const Gap(16),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: AppHeader(),
),
Padding(
padding: const EdgeInsets.all(8),
child: Column(
Sidebar(),
Gap(1),
VerticalDivider(),
Expanded(
child: Stack(
children: [
SizedBox(
width: double.infinity,
child: Button.ghost(
leading: const Icon(LucideIcons.folderPlus),
leadingGap: 12,
onPressed: _pickProjectDirectory,
child: Transform.translate(
offset: const Offset(0, 1),
child: const Align(
alignment: Alignment.centerLeft,
child: Text("New Project"),
),
),
),
),
const Gap(8),
SizedBox(
width: double.infinity,
child: Button.ghost(
leading: const Icon(LucideIcons.circlePlus),
leadingGap: 12,
onPressed:
selectedProject == null || chatProvider.isLoading
? null
: _createNewChat,
child: Transform.translate(
offset: const Offset(0, 1),
child: const Align(
alignment: Alignment.centerLeft,
child: Text("New Chat"),
),
),
),
_ChatArea(scrollController: _chatScrollController),
Positioned(
top: 0,
bottom: 0,
right: 0,
width: 12,
child: FullHeightScrollbar(controller: _chatScrollController),
),
],
),
),
const Divider(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text("All Threads").textSmall.muted,
),
Expanded(
child: _ThreadsSection(
projectsProvider: projectsProvider,
sessionProvider: sessionProvider,
sessionsByProject: sessionsByProject,
onOpenSession: _openSession,
onSelectProject: _selectProject,
),
),
AgentsPane(),
],
),
),
const VerticalDivider(),
Expanded(
child: Column(
children: [
if (selectedProject != null && sessionProvider.currentSession != null)...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12
),
child: Row(
children: [
FooterBar(),
Icon(
LucideIcons.messageCircle
).iconSmall,
Gap(8),
Transform.translate(
offset: Offset(0, -1),
child: Row(
children: [
Text(
selectedProject.name
).textSmall,
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Icon(
LucideIcons.slash
).iconX2Small,
),
Text(
sessionProvider.currentSession!.name
).textSmall
],
),
),
],
),
),
Divider(),
],
const Gap(18),
Expanded(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 600
),
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 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(
chatProvider.isStopping
? "Stopping..."
: "Stop",
)
: const Text("Send"),
),
],
),
),
],
),
],
),
),
)
],
),
),
],
),
);
}
}
class _SidebarHint extends StatelessWidget {
const _SidebarHint({required this.text});
final String text;
class _ChatArea extends StatelessWidget {
final ScrollController scrollController;
const _ChatArea({required this.scrollController});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Text(text).textSmall.muted,
);
}
}
final chatProvider = context.watch<ChatProvider>();
class _ThreadsSection extends StatelessWidget {
const _ThreadsSection({
required this.projectsProvider,
required this.sessionProvider,
required this.sessionsByProject,
required this.onOpenSession,
required this.onSelectProject,
});
final ProjectsProvider projectsProvider;
final SessionProvider sessionProvider;
final Map<String, List<SessionSummary>> sessionsByProject;
final ValueChanged<SessionSummary> onOpenSession;
final ValueChanged<ProjectRecord> onSelectProject;
@override
Widget build(BuildContext context) {
// Sort sessions by update time (newest first) within each project
final sortedSessionsByProject = <String, List<SessionSummary>>{};
sessionsByProject.forEach((workingDirectory, sessions) {
final sortedSessions = List<SessionSummary>.from(sessions)
..sort((a, b) => b.updated.compareTo(a.updated));
sortedSessionsByProject[workingDirectory] = sortedSessions;
});
return ListView(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
children: [
if (projectsProvider.projects.isEmpty)
const _SidebarHint(text: "No projects yet")
else
for (final project in projectsProvider.projects)
...[
// Project header
SizedBox(
width: double.infinity,
child: Button.ghost(
onPressed: () => onSelectProject(project),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
project.name,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 12,
color: Theme.of(context).colorScheme.mutedForeground,
),
),
),
),
),
// Project sessions
if (sortedSessionsByProject[project.workingDirectory]?.isEmpty ?? true)
const Padding(
padding: EdgeInsets.fromLTRB(8, 4, 8, 8),
child: _SidebarHint(text: "No threads yet"),
)
else
for (final session in sortedSessionsByProject[project.workingDirectory]!)
_SidebarSessionTile(
session: session,
isSelected: sessionProvider.currentSessionId == session.id,
onTap: () => onOpenSession(session),
),
const Divider(height: 16),
],
// Handle sessions that don't belong to any current project
if (sortedSessionsByProject.keys.any((key) => !projectsProvider.projects.any((project) => project.workingDirectory == key)))
...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: Text(
"Sessions Without Projects",
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 12,
color: Theme.of(context).colorScheme.mutedForeground,
),
),
),
for (final entry in sortedSessionsByProject.entries)
if (!projectsProvider.projects.any((project) => project.workingDirectory == entry.key) && entry.key.isNotEmpty)
for (final session in entry.value)
_SidebarSessionTile(
session: session,
isSelected: sessionProvider.currentSessionId == session.id,
onTap: () => onOpenSession(session),
),
],
],
);
}
}
class _SidebarSessionTile extends StatelessWidget {
const _SidebarSessionTile({
required this.session,
required this.isSelected,
required this.onTap,
});
final SessionSummary session;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: Button(
style: isSelected ? ButtonStyle.secondary() : ButtonStyle.ghost(),
child: Text(
session.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontWeight: FontWeight.w400, fontSize: 13),
).textSmall,
trailing: Text(
_formatRelativeTime(session.updated),
style: TextStyle(fontWeight: FontWeight.w400, fontSize: 13),
).muted.textSmall,
onPressed: () {
onTap();
},
),
);
}
}
class _EmptyChatState extends StatelessWidget {
const _EmptyChatState({required this.projectName, required this.hasProject});
final String? projectName;
final bool hasProject;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
return Container(
alignment: Alignment.center,
padding: const EdgeInsets.all(16),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(LucideIcons.messagesSquare, size: 28),
const Gap(16),
Text(
hasProject
? "Ready to chat about ${projectName ?? "this project"}"
: "Choose a project to begin",
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
textAlign: TextAlign.center,
Expanded(
child: chatProvider.messages.isEmpty
? _EmptyChatState()
: ChatView(scrollController: scrollController),
),
const Gap(8),
Text(
hasProject
? "This chat will use the selected folder as its working directory."
: "The desktop app uses the picked folder instead of the shell launch directory.",
textAlign: TextAlign.center,
).textSmall.muted,
ChatBox(),
],
),
),
@@ -675,23 +144,92 @@ class _EmptyChatState extends StatelessWidget {
}
}
String _formatRelativeTime(DateTime timestamp) {
final difference = DateTime.now().toUtc().difference(timestamp.toUtc());
if (difference.inMinutes < 1) {
return "just now";
}
if (difference.inHours < 1) {
return "${difference.inMinutes}m";
}
if (difference.inDays < 1) {
return "${difference.inHours}h";
}
if (difference.inDays < 7) {
return "${difference.inDays}d";
}
class _EmptyChatState extends StatelessWidget {
final month = timestamp.month.toString().padLeft(2, "0");
final day = timestamp.day.toString().padLeft(2, "0");
return "${timestamp.year}-$month-$day";
const _EmptyChatState();
@override
Widget build(BuildContext context) {
final projectsProvider = context.watch<ProjectsProvider>();
final projects = projectsProvider.projects;
final selected = projectsProvider.selectedProject;
final coordinator = context.read<HomeCoordinator>();
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(LucideIcons.messagesSquare, size: 28),
const Gap(16),
Text(
"Ask the agency anything",
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
textAlign: TextAlign.center,
),
const Gap(8),
Text(
"Select a project and thread from the sidebar, or start a new chat.",
textAlign: TextAlign.center,
).textSmall.muted,
const Gap(24),
Select<ProjectRecord>(
itemBuilder: (context, item) => Text(item.name),
popup: SelectPopup.builder(
searchPlaceholder: const Text("Search projects"),
builder: (context, searchQuery) {
final filtered = searchQuery == null || searchQuery.isEmpty
? projects
: projects.where((p) =>
p.name.toLowerCase().contains(searchQuery.toLowerCase()) ||
p.workingDirectory.toLowerCase().contains(searchQuery.toLowerCase())
).toList();
return SelectItemList(
children: [
for (final project in filtered)
SelectItemButton(
value: project,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(project.name),
Text(project.workingDirectory).textSmall.muted,
],
),
),
],
);
},
),
onChanged: (project) {
if (project != null) coordinator.selectProject(project);
},
constraints: const BoxConstraints(minWidth: 240),
value: selected,
placeholder: const Text("Select a project"),
),
],
),
),
);
}
}
abstract class HomeScreenRoute {
static const path = '/';
static const name = 'home';
static GoRoute get route => GoRoute(
path: path,
name: name,
builder: (context, state) => const NewHomeScreen(),
);
}
@@ -0,0 +1,193 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../../src/project_store.dart";
import "../../../../src/session/session_types.dart";
import "../../../providers/projects_provider.dart";
import "../../../providers/session_provider.dart";
class ThreadsSection extends StatelessWidget {
const ThreadsSection({
required this.projectsProvider,
required this.sessionProvider,
required this.sessionsByProject,
required this.onOpenSession,
required this.onSelectProject,
required this.onDeleteSession,
});
final ProjectsProvider projectsProvider;
final SessionProvider sessionProvider;
final Map<String, List<SessionSummary>> sessionsByProject;
final ValueChanged<SessionSummary> onOpenSession;
final ValueChanged<ProjectRecord> onSelectProject;
final ValueChanged<SessionSummary> onDeleteSession;
@override
Widget build(BuildContext context) {
// Sort sessions by update time (newest first) within each project
final sortedSessionsByProject = <String, List<SessionSummary>>{};
sessionsByProject.forEach((workingDirectory, sessions) {
final sortedSessions = List<SessionSummary>.from(sessions)
..sort((a, b) => b.updated.compareTo(a.updated));
sortedSessionsByProject[workingDirectory] = sortedSessions;
});
return ListView(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
children: [
if (projectsProvider.projects.isEmpty)
const _SidebarHint(text: "No projects yet")
else
for (final project in projectsProvider.projects) ...[
// Project header
SizedBox(
width: double.infinity,
child: Button.ghost(
onPressed: () => onSelectProject(project),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
project.name,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 12,
color: Theme.of(context).colorScheme.mutedForeground,
),
),
),
),
),
// Project sessions
if (sortedSessionsByProject[project.workingDirectory]?.isEmpty ??
true)
const Padding(
padding: EdgeInsets.fromLTRB(8, 4, 8, 8),
child: _SidebarHint(text: "No threads yet"),
)
else
for (final session
in sortedSessionsByProject[project.workingDirectory]!)
_SidebarSessionTile(
session: session,
isSelected: sessionProvider.currentSessionId == session.id,
onTap: () => onOpenSession(session),
onDelete: () => onDeleteSession(session),
),
const Divider(height: 16),
],
// Handle sessions that don't belong to any current project
if (sortedSessionsByProject.keys.any(
(key) => !projectsProvider.projects.any(
(project) => project.workingDirectory == key,
),
)) ...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: Text(
"Sessions Without Projects",
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 12,
color: Theme.of(context).colorScheme.mutedForeground,
),
),
),
for (final entry in sortedSessionsByProject.entries)
if (!projectsProvider.projects.any(
(project) => project.workingDirectory == entry.key,
) &&
entry.key.isNotEmpty)
for (final session in entry.value)
_SidebarSessionTile(
session: session,
isSelected: sessionProvider.currentSessionId == session.id,
onTap: () => onOpenSession(session),
onDelete: () => onDeleteSession(session),
),
],
],
);
}
}
class _SidebarHint extends StatelessWidget {
const _SidebarHint({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Text(text).textSmall.muted,
);
}
}
class _SidebarSessionTile extends StatelessWidget {
const _SidebarSessionTile({
required this.session,
required this.isSelected,
required this.onTap,
required this.onDelete,
});
final SessionSummary session;
final bool isSelected;
final VoidCallback onTap;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
return ContextMenu(
items: [
MenuButton(
onPressed: (context) {
onDelete();
},
child: const Text("Delete"),
),
],
child: SizedBox(
width: double.infinity,
child: Button(
style: isSelected ? ButtonStyle.secondary() : ButtonStyle.ghost(),
child: Text(
session.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontWeight: FontWeight.w400, fontSize: 13),
).textSmall,
trailing: Text(
_formatRelativeTime(session.updated),
style: TextStyle(fontWeight: FontWeight.w400, fontSize: 13),
).muted.textSmall,
onPressed: () {
onTap();
},
),
),
);
}
}
String _formatRelativeTime(DateTime timestamp) {
final difference = DateTime.now().toUtc().difference(timestamp.toUtc());
if (difference.inMinutes < 1) {
return "just now";
}
if (difference.inHours < 1) {
return "${difference.inMinutes}m";
}
if (difference.inDays < 1) {
return "${difference.inHours}h";
}
if (difference.inDays < 7) {
return "${difference.inDays}d";
}
final month = timestamp.month.toString().padLeft(2, "0");
final day = timestamp.day.toString().padLeft(2, "0");
return "${timestamp.year}-$month-$day";
}
+107
View File
@@ -0,0 +1,107 @@
import "package:go_router/go_router.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class ProjectDetailPage extends StatelessWidget {
const ProjectDetailPage({
super.key,
required this.projectId,
this.tab = 'overview',
});
final String projectId;
final String tab;
@override
Widget build(BuildContext context) {
return Scaffold(
headers: [
AppBar(
title: Text("Project: $projectId"),
leading: [
IconButton.ghost(
icon: const Icon(LucideIcons.arrowLeft),
onPressed: () => context.go('/'),
),
],
),
],
child: Column(
children: [
// Tab navigation
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
_buildTabButton(context, 'overview', 'Overview'),
const Gap(8),
_buildTabButton(context, 'files', 'Files'),
const Gap(8),
_buildTabButton(context, 'settings', 'Settings'),
],
),
),
const Divider(),
Expanded(
child: Padding(
padding: const EdgeInsets.all(16),
child: _buildTabContent(),
),
),
],
),
);
}
Widget _buildTabButton(BuildContext context, String tabName, String label) {
final isActive = tab == tabName;
return Button(
style: isActive ? ButtonStyle.secondary() : ButtonStyle.ghost(),
onPressed: () => context.go(
ProjectDetailRoute.pathWithParams(
projectId: projectId,
tab: tabName,
),
),
child: Text(label),
);
}
Widget _buildTabContent() {
switch (tab) {
case 'files':
return const Center(child: Text("Files tab content"));
case 'settings':
return const Center(child: Text("Settings tab content"));
case 'overview':
default:
return const Center(child: Text("Project overview content"));
}
}
}
/// GoRouter routes for the project detail page
abstract class ProjectDetailRoute {
static const path = '/projects/:projectId';
static const name = 'project_detail';
static String pathWithParams({
required String projectId,
String tab = 'overview',
}) {
return '/projects/$projectId?tab=$tab';
}
static GoRoute get route => GoRoute(
path: path,
name: name,
builder: (context, state) {
final projectId = state.pathParameters['projectId']!;
final tab = state.uri.queryParameters['tab'] ?? 'overview';
return ProjectDetailPage(
projectId: projectId,
tab: tab,
);
},
);
}
+68
View File
@@ -0,0 +1,68 @@
import "package:go_router/go_router.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "widgets/setting_card.dart";
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
headers: [
AppBar(
title: const Text("Settings"),
leading: [
IconButton.ghost(
icon: const Icon(LucideIcons.arrowLeft),
onPressed: () => context.go('/'),
),
],
),
],
child: ListView(
padding: const EdgeInsets.all(16),
children: [
SettingCard(
title: "Appearance",
description: "Customize theme, colors, and layout",
icon: LucideIcons.palette,
onTap: () {
// Could navigate to appearance settings
},
),
const Gap(12),
SettingCard(
title: "Models",
description: "Configure AI model preferences",
icon: LucideIcons.brain,
onTap: () {
// Could navigate to model settings
},
),
const Gap(12),
SettingCard(
title: "Advanced",
description: "Developer options and advanced settings",
icon: LucideIcons.settings2,
onTap: () {
// Could navigate to advanced settings
},
),
],
),
);
}
}
/// GoRouter routes for the settings page
abstract class SettingsRoute {
static const path = '/settings';
static const name = 'settings';
static GoRoute get route => GoRoute(
path: path,
name: name,
builder: (context, state) => const SettingsPage(),
);
}
@@ -0,0 +1,48 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
class SettingCard extends StatelessWidget {
const SettingCard({
super.key,
required this.title,
required this.description,
required this.icon,
this.onTap,
});
final String title;
final String description;
final IconData icon;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(icon).iconLarge,
const Gap(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title).textLarge,
const Gap(4),
Text(description).textSmall.muted,
],
),
),
if (onTap != null) ...[
const Gap(8),
IconButton.ghost(
onPressed: onTap,
icon: const Icon(LucideIcons.chevronRight),
),
],
],
),
),
);
}
}
+151 -18
View File
@@ -3,48 +3,130 @@ import "dart:convert";
import "../../src/chat/tool_loop_service.dart";
import "../../src/api/openrouter_client.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_types.dart";
import "../../src/services/cost_tracker.dart" as cost_tracker;
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});
}
class ChatProvider extends ChangeNotifier {
ChatProvider(this._settingsProvider);
ChatProvider(this._settingsProvider) {
_initHooks();
}
final SettingsProvider _settingsProvider;
final ToolLoopService _toolLoopService = ToolLoopService();
ToolLoopService _toolLoopService = ToolLoopService();
HookRunner? _hookRunner;
ConversationHistory? _conversationHistory;
OpenRouterClient? _client;
bool _stopRequested = false;
PendingPermission? _pendingPermission;
PendingPermission? get pendingPermission => _pendingPermission;
Future<void> _initHooks() async {
try {
final hooks = await HookLoader.loadHooks();
_hookRunner = HookRunner(hooks: hooks);
_toolLoopService = ToolLoopService(hookRunner: _hookRunner);
} catch (e) {
// hooks are optional, carry on without them
print("Hook init failed: $e");
}
}
List<Message> _messages = <Message>[];
List<Map<String, dynamic>> _apiMessages = <Map<String, dynamic>>[];
bool isLoading = false;
final List<QueuedMessage> _messageQueue = [];
List<Message> get messages => _messages;
int get messageCount => _messages.length;
List<Message> get messages => _conversationHistory?.getMessages() ?? const [];
int get messageCount => messages.length;
String? get workingDirectory => _conversationHistory?.session?.workingDirectory;
/// 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--) {
final ct = msgs[i].contextTokens;
if (ct != null && ct > 0) return ct;
}
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));
void removeQueuedMessage(int index) {
if (index < 0 || index >= _messageQueue.length) return;
_messageQueue.removeAt(index);
notifyListeners();
}
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;
_messages = history.getMessages();
_apiMessages = _buildApiMessages(_messages);
_apiMessages = _buildApiMessages(history.getMessages());
notifyListeners();
}
void clearConversation() {
_conversationHistory = null;
_messages = <Message>[];
_apiMessages = <Map<String, dynamic>>[];
_messageQueue.clear();
isLoading = false;
notifyListeners();
}
Future<void> sendMessage(String text) async {
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(
@@ -72,25 +154,35 @@ class ChatProvider extends ChangeNotifier {
}
}
// fire UserPromptSubmit hook
await _hookRunner?.runHooksForKind(
HookKind.userPromptSubmit,
input: {"message": text},
);
// add user message to conversation
_conversationHistory!.addMessage("user", text);
_messages = _conversationHistory!.getMessages();
_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,
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),
);
_messages = _conversationHistory!.getMessages();
notifyListeners();
},
onToolResult: (toolName, result) {
@@ -98,7 +190,6 @@ class ChatProvider extends ChangeNotifier {
"tool",
_formatToolResult(toolName, result),
);
_messages = _conversationHistory!.getMessages();
notifyListeners();
},
onAssistantTextDelta: (delta) {
@@ -107,26 +198,38 @@ class ChatProvider extends ChangeNotifier {
hasStreamingAssistantMessage = true;
}
_conversationHistory!.appendToLastMessage(delta);
_messages = _conversationHistory!.getMessages();
notifyListeners();
},
onAssistantMessageComplete: () {
hasStreamingAssistantMessage = false;
_messages = _conversationHistory!.getMessages();
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;
},
);
_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);
}
_messages = _conversationHistory!.getMessages();
// track cost (set to 0 for now — OpenRouter pricing varies by model)
final inputTokens = toolLoopResult.response.inputTokens ?? 0;
@@ -138,6 +241,8 @@ class ChatProvider extends ChangeNotifier {
outputTokens: outputTokens,
cacheReadTokens: 0,
cacheCreationTokens: 0,
webSearchRequests: toolLoopResult.webSearchRequests,
webFetchRequests: toolLoopResult.webFetchRequests,
model: toolLoopResult.response.model,
);
@@ -154,7 +259,7 @@ class ChatProvider extends ChangeNotifier {
if (error is RequestCancelledException) {
_conversationHistory!.addMessage("assistant", "Generation stopped.");
final session = _conversationHistory!.session;
_messages = _conversationHistory!.getMessages();
if (session != null) {
await SessionStore.instance.saveSession(session);
}
@@ -171,7 +276,7 @@ class ChatProvider extends ChangeNotifier {
);
final session = _conversationHistory!.session;
_messages = _conversationHistory!.getMessages();
if (session != null) {
await SessionStore.instance.saveSession(session);
}
@@ -183,6 +288,26 @@ class ChatProvider extends ChangeNotifier {
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;
notifyListeners();
}
void stopGenerating() {
@@ -190,10 +315,15 @@ class ChatProvider extends ChangeNotifier {
return;
}
_pendingPermission?.resolve(PermissionDecision.reject);
_pendingPermission = null;
_messageQueue.clear();
_stopRequested = true;
print("Stopping active turn");
_client?.cancelActiveRequest();
notifyListeners();
_hookRunner?.runHooksForKind(HookKind.stop);
}
@override
@@ -232,7 +362,10 @@ class ChatProvider extends ChangeNotifier {
String _formatToolCall(String toolName, Map<String, dynamic> input) {
const encoder = JsonEncoder.withIndent(" ");
return "$toolName call\n${encoder.convert(input)}";
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) {
+126
View File
@@ -0,0 +1,126 @@
import "package:file_picker/file_picker.dart";
import "package:flutter/foundation.dart";
import "../../src/project_store.dart";
import "../../src/session/session_types.dart";
import "chat_provider.dart";
import "projects_provider.dart";
import "session_provider.dart";
import "settings_provider.dart";
class HomeCoordinator extends ChangeNotifier {
HomeCoordinator(this._projects, this._session, this._chat, this._settings);
final ProjectsProvider _projects;
final SessionProvider _session;
final ChatProvider _chat;
final SettingsProvider _settings;
String? _error;
String? get error => _error;
void clearError() {
_error = null;
notifyListeners();
}
void _setError(String msg) {
_error = msg;
notifyListeners();
}
Future<void> pickProjectDirectory() async {
try {
final selectedDirectory = await FilePicker.platform.getDirectoryPath(
dialogTitle: "Select project directory",
);
if (selectedDirectory == null) return;
final project = await _projects.addProject(selectedDirectory);
if (project == null) {
_setError("The selected folder could not be added as a project.");
return;
}
_projects.selectProject(project.id);
_session.clearCurrentSession(workingDirectory: project.workingDirectory);
_chat.clearConversation();
await _settings.setActiveProject(project.workingDirectory);
} catch (e, st) {
print("Project directory picker failed: $e");
print(st);
_setError(e.toString());
}
}
Future<void> createNewChat() async {
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());
}
Future<void> selectProject(ProjectRecord project) async {
_projects.selectProject(project.id);
await _settings.setActiveProject(project.workingDirectory);
if (_session.currentSession?.workingDirectory == project.workingDirectory) return;
_session.clearCurrentSession(workingDirectory: project.workingDirectory);
_settings.setThreadModel(null);
_chat.clearConversation();
}
Future<void> openSession(SessionSummary session) async {
await _session.loadSession(session);
_chat.setConversation(_session.getConversationHistory());
_projects.selectProjectByWorkingDirectory(_session.activeWorkingDirectory);
_settings.setThreadModel(_session.currentSession?.model);
}
Future<void> sendMessage(String text) async {
if (text.isEmpty) return;
if (_session.currentSession == null) {
final selectedProject = _projects.selectedProject;
if (selectedProject == null) {
_setError("Pick a project before starting a chat.");
return;
}
await _session.createNewSession(
workingDirectory: selectedProject.workingDirectory,
name: "New Chat",
model: _settings.settings.model,
);
_settings.setThreadModel(_settings.settings.model);
_chat.setConversation(_session.getConversationHistory());
}
try {
await _chat.sendMessage(text);
} catch (e, st) {
print("Failed to send message: $e");
print(st);
_setError(e.toString());
} finally {
await _session.refreshSessions();
}
}
Future<void> deleteSession(SessionSummary session) async {
await _session.deleteSession(session);
}
}
+37 -10
View File
@@ -1,15 +1,17 @@
import "package:flutter/foundation.dart";
import "package:uuid/uuid.dart";
import "../../src/project_store.dart";
import "../../src/session/conversation_history.dart";
import "../../src/session/session_store.dart";
import "../../src/session/session_types.dart";
class SessionProvider extends ChangeNotifier {
SessionProvider() {
SessionProvider(this._projectStore) {
_loadSessions();
}
final ProjectStore _projectStore;
final SessionStore _sessionStore = SessionStore.instance;
final ConversationHistory _conversationHistory = ConversationHistory();
@@ -59,7 +61,12 @@ class SessionProvider extends ChangeNotifier {
Future<void> _loadSessions() async {
try {
_sessions = await _sessionStore.listSessions();
final workingDirs = _projectStore.projects
.map((p) => p.workingDirectory)
.where((d) => d.isNotEmpty)
.toList();
_sessions = await _sessionStore.listAllSessions(workingDirs);
notifyListeners();
} catch (error, stackTrace) {
_logException("Failed to load sessions", error, stackTrace);
@@ -70,6 +77,7 @@ class SessionProvider extends ChangeNotifier {
Future<void> createNewSession({
String? workingDirectory,
String? name,
String? model,
}) async {
try {
const uuid = Uuid();
@@ -86,6 +94,7 @@ class SessionProvider extends ChangeNotifier {
normalizedDirectory == null || normalizedDirectory.isEmpty
? null
: normalizedDirectory,
model: model,
);
await _sessionStore.saveSession(newSession);
@@ -101,29 +110,38 @@ class SessionProvider extends ChangeNotifier {
}
}
Future<void> loadSession(String id) async {
Future<void> loadSession(SessionSummary summary) async {
try {
final session = await _sessionStore.loadSession(id);
final workingDir = summary.workingDirectory;
if (workingDir == null || workingDir.isEmpty) return;
final session = await _sessionStore.loadSession(
summary.id,
workingDirectory: workingDir,
);
if (session != null) {
_conversationHistory.setSession(session);
_currentSession = session;
_currentSessionId = id;
_currentSessionId = summary.id;
_activeWorkingDirectory = session.workingDirectory;
notifyListeners();
}
} catch (error, stackTrace) {
_logException("Failed to load session $id", error, stackTrace);
_logException("Failed to load session ${summary.id}", error, stackTrace);
_currentSession = null;
_currentSessionId = null;
_activeWorkingDirectory = null;
}
}
Future<void> deleteSession(String id) async {
Future<void> deleteSession(SessionSummary summary) async {
try {
await _sessionStore.deleteSession(id);
final workingDir = summary.workingDirectory;
if (workingDir == null || workingDir.isEmpty) return;
if (_currentSessionId == id) {
await _sessionStore.deleteSession(summary.id, workingDirectory: workingDir);
if (_currentSessionId == summary.id) {
_conversationHistory.setSession(
ConversationSession(
id: "",
@@ -140,7 +158,7 @@ class SessionProvider extends ChangeNotifier {
await _loadSessions();
notifyListeners();
} catch (error, stackTrace) {
_logException("Failed to delete session $id", error, stackTrace);
_logException("Failed to delete session ${summary.id}", error, stackTrace);
}
}
@@ -152,6 +170,15 @@ class SessionProvider extends ChangeNotifier {
}
}
// Updates the model on the current in-memory session and persists it
Future<void> updateSessionModel(String model) async {
final session = _currentSession;
if (session == null) return;
session.model = model;
await _sessionStore.saveSession(session);
}
ConversationHistory getConversationHistory() => _conversationHistory;
void _logException(String message, Object error, StackTrace stackTrace) {
+70 -9
View File
@@ -1,16 +1,31 @@
import "package:flutter/foundation.dart";
import "../../src/local_state.dart";
import "../../src/project_settings_store.dart";
class SettingsProvider extends ChangeNotifier {
SettingsProvider(this._settingsStore) : settings = _settingsStore.settings;
SettingsProvider(this._settingsStore) : _globalSettings = _settingsStore.settings;
static const Map<String, String> _legacyModelAliases = {
"google/gemini-2.0-flash": "google/gemini-2.0-flash-001",
};
final SettingsStore _settingsStore;
LocalSettings settings;
LocalSettings _globalSettings;
LocalSettings? _projectSettings;
String? _threadModel;
String? _activeProjectDir;
// Effective settings: global → project override → thread model
LocalSettings get settings {
var merged = _globalSettings.mergeWith(_projectSettings);
if (_threadModel != null && _threadModel!.isNotEmpty) {
merged = merged.copyWith(model: _threadModel);
}
return merged;
}
String normalizeModelId(String? modelId) {
if (modelId == null || modelId.isEmpty) {
@@ -20,12 +35,36 @@ class SettingsProvider extends ChangeNotifier {
return _legacyModelAliases[modelId] ?? modelId;
}
// Called when the active project changes
Future<void> setActiveProject(String? workingDirectory) async {
_activeProjectDir = workingDirectory;
_projectSettings = null;
_threadModel = null;
if (workingDirectory != null && workingDirectory.isNotEmpty) {
_projectSettings = await ProjectSettingsStore.instance.load(workingDirectory);
}
notifyListeners();
}
// Called when a thread is loaded or cleared
void setThreadModel(String? model) {
_threadModel = model != null ? normalizeModelId(model) : null;
notifyListeners();
}
Future<void> updateModel(String newModel) async {
final normalizedModel = normalizeModelId(newModel);
final normalized = normalizeModelId(newModel);
// update thread model in memory
_threadModel = normalized;
// also persist to global settings as the new default
await _settingsStore.update(
(current) => current.copyWith(model: normalizedModel),
(current) => current.copyWith(model: normalized),
);
settings = _settingsStore.settings;
_globalSettings = _settingsStore.settings;
notifyListeners();
}
@@ -33,13 +72,13 @@ class SettingsProvider extends ChangeNotifier {
await _settingsStore.update(
(current) => current.copyWith(openRouterApiKey: newKey),
);
settings = _settingsStore.settings;
_globalSettings = _settingsStore.settings;
notifyListeners();
}
Future<void> updateTheme(String newTheme) async {
await _settingsStore.update((current) => current.copyWith(theme: newTheme));
settings = _settingsStore.settings;
_globalSettings = _settingsStore.settings;
notifyListeners();
}
@@ -47,13 +86,35 @@ class SettingsProvider extends ChangeNotifier {
await _settingsStore.update(
(current) => current.copyWith(effortLevel: newLevel),
);
settings = _settingsStore.settings;
_globalSettings = _settingsStore.settings;
notifyListeners();
}
Future<void> addAlwaysAllowRule(String toolName) async {
final current = _globalSettings.alwaysAllowRules;
if (current.contains(toolName)) return;
await _settingsStore.update(
(s) => s.copyWith(alwaysAllowRules: [...current, toolName]),
);
_globalSettings = _settingsStore.settings;
notifyListeners();
}
Future<void> resetToDefaults() async {
await _settingsStore.update((_) => const LocalSettings());
settings = _settingsStore.settings;
_globalSettings = _settingsStore.settings;
_projectSettings = null;
_threadModel = null;
notifyListeners();
}
// Save project-level settings override
Future<void> updateProjectSetting(LocalSettings projectOverride) async {
final dir = _activeProjectDir;
if (dir == null || dir.isEmpty) return;
await ProjectSettingsStore.instance.save(dir, projectOverride);
_projectSettings = projectOverride;
notifyListeners();
}
}
+22
View File
@@ -0,0 +1,22 @@
import "package:go_router/go_router.dart";
import "../pages/home_screen/page.dart";
import "../pages/settings/page.dart";
import "../pages/project_detail/page.dart";
/// Application router configuration
class AppRouter {
/// List of all routes in the application
static final routes = [
HomeScreenRoute.route,
SettingsRoute.route,
ProjectDetailRoute.route,
];
/// The main GoRouter instance
static final GoRouter router = GoRouter(
routes: routes,
initialLocation: HomeScreenRoute.path,
debugLogDiagnostics: true,
);
}
+19
View File
@@ -0,0 +1,19 @@
String formatRelativeTime(DateTime timestamp) {
final difference = DateTime.now().toUtc().difference(timestamp.toUtc());
if (difference.inMinutes < 1) {
return "Just now";
}
if (difference.inHours < 1) {
return "${difference.inMinutes}m";
}
if (difference.inDays < 1) {
return "${difference.inHours}h";
}
if (difference.inDays < 7) {
return "${difference.inDays}d";
}
final weeks = difference.inDays ~/ 7;
return "${weeks}w";
}
+16
View File
@@ -0,0 +1,16 @@
import "package:path/path.dart" as p;
String shortenPath(String fullPath, String? projectRoot) {
if (projectRoot == null || projectRoot.isEmpty) return fullPath;
final root = p.normalize(projectRoot);
final norm = p.normalize(fullPath);
if (norm.startsWith(root)) {
final rel = norm.substring(root.length);
// trim leading separator
return rel.startsWith(p.separator) ? rel.substring(1) : rel;
}
return fullPath;
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
class AgentsPane extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: OutlinedContainer(
width: 300,
child: Column(
children: [
],
)
),
);
}
}
+44
View File
@@ -0,0 +1,44 @@
import "package:gpt_markdown/gpt_markdown.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class AdvisorMessage extends StatelessWidget {
const AdvisorMessage({super.key, required this.title, required this.body});
final String title;
final String body;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
OutlinedContainer(
padding: const EdgeInsets.all(10),
backgroundColor: theme.colorScheme.primary,
child: Icon(LucideIcons.brain).iconSmall,
),
Gap(8),
Text(
title,
style: theme.typography.p.copyWith(fontSize: 13),
),
],
),
if (body.isNotEmpty) ...[
Gap(8),
OutlinedContainer(
padding: const EdgeInsets.all(12),
child: GptMarkdown(body),
),
],
],
);
}
}
+143
View File
@@ -0,0 +1,143 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import '../../models/attachment.dart';
import '../common/button.dart';
class AttachmentPreview extends StatelessWidget {
final List<Attachment> attachments;
final Function(int) onRemove;
const AttachmentPreview({
required this.attachments,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
if (attachments.isEmpty) {
return SizedBox.shrink();
}
return MouseRegion(
cursor: SystemMouseCursors.basic,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (int i = 0; i < attachments.length; i++)
Padding(
padding: EdgeInsets.only(right: 8),
child: AttachmentItem(
attachment: attachments[i],
onRemove: () => onRemove(i),
),
),
],
),
),
),
);
}
}
class AttachmentItem extends StatelessWidget {
final Attachment attachment;
final VoidCallback onRemove;
const AttachmentItem({
required this.attachment,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
String sanitisedName = attachment.displayName;
String type = attachment.mimeType.split("/").last.toUpperCase();
return OutlinedContainer(
height: 52,
borderRadius: Theme.of(context).borderRadiusSm,
padding: EdgeInsets.all(8),
borderColor: Theme.of(context).colorScheme.border,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
OutlinedContainer(
borderRadius: BorderRadius.circular(
Theme.of(context).radiusSm - 4
),
child: AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.zero,
child: _buildPreview(context),
),
),
),
Gap(8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
sanitisedName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
).small.semiBold,
Gap(2),
Text(
type,
maxLines: 1,
overflow: TextOverflow.ellipsis,
).extraLight.small,
],
),
Gap(8),
SizedBox(
child: ClipRRect(
borderRadius: BorderRadius.circular(
Theme.of(context).radiusSm - 4
),
child: AspectRatio(
aspectRatio: 1,
child: AgcGhostButton(
onPressed: onRemove,
child: Icon(LucideIcons.x, size: 14),
),
),
),
)
],
),
);
}
Widget _buildPreview(BuildContext context) {
if (attachment.isImage) {
return Image.memory(
attachment.data,
fit: BoxFit.cover,
);
}
final icon = _getIconForMimeType(attachment.mimeType);
return Container(
color: Theme.of(context).colorScheme.muted,
child: Icon(icon).iconMedium,
);
}
IconData _getIconForMimeType(String mimeType) {
if (mimeType == 'application/pdf') {
return LucideIcons.book;
} else if (mimeType.startsWith('text/') || mimeType == 'application/json') {
return LucideIcons.fileText;
} else if (mimeType.startsWith('image/')) {
return LucideIcons.image;
} else {
return LucideIcons.file;
}
}
}
@@ -0,0 +1,13 @@
import "package:gpt_markdown/gpt_markdown.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class AssistantBubble extends StatelessWidget {
const AssistantBubble({super.key, required this.content});
final String content;
@override
Widget build(BuildContext context) {
return GptMarkdown(content);
}
}
@@ -0,0 +1 @@
export "../../../../src/permissions/permission_types.dart" show PermissionDecision;
@@ -0,0 +1,89 @@
import "dart:convert";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "tools/advisor_bubble.dart";
import "tools/bash_bubble.dart";
import "tools/default_tool_bubble.dart";
import "tools/edit_bubble.dart";
import "tools/glob_bubble.dart";
import "tools/grep_bubble.dart";
import "tools/read_bubble.dart";
import "tools/web_fetch_bubble.dart";
import "tools/web_search_bubble.dart";
import "tools/write_bubble.dart";
class ToolBubble extends StatelessWidget {
const ToolBubble({
super.key,
required this.toolName,
this.toolInput,
this.result,
this.isPendingPermission = false,
});
final String toolName;
final Map<String, dynamic>? toolInput;
final String? result;
final bool isPendingPermission;
// parse a tool message content string into (toolName, toolInput)
// format: "$toolName call\n{json}" or "$toolName result\n..."
static (String, Map<String, dynamic>?) parseContent(String content) {
final newlineIdx = content.indexOf("\n");
if (newlineIdx == -1) {
// no body, just a label line
final name = _extractName(content);
return (name, null);
}
final firstLine = content.substring(0, newlineIdx).trim();
final rest = content.substring(newlineIdx + 1).trim();
final name = _extractName(firstLine);
if (firstLine.endsWith(" call") && rest.isNotEmpty) {
try {
final decoded = jsonDecode(rest);
if (decoded is Map<String, dynamic>) {
return (name, decoded);
}
} catch (_) {}
}
return (name, null);
}
static String _extractName(String line) {
// strip trailing " call" or " result"
if (line.endsWith(" call")) return line.substring(0, line.length - 5).trim();
if (line.endsWith(" result")) return line.substring(0, line.length - 7).trim();
return line.trim();
}
@override
Widget build(BuildContext context) {
final input = toolInput ?? {};
switch (toolName) {
case "Bash":
return BashBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "Edit":
return EditBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "Read":
return ReadBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "Write":
return WriteBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "Glob":
return GlobBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "Grep":
return GrepBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "WebSearch":
return WebSearchBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "WebFetch":
return WebFetchBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "Advisor":
return AdvisorBubble(input: input, result: result, isPendingPermission: isPendingPermission);
default:
return DefaultToolBubble(toolName: toolName, input: toolInput, result: result, isPendingPermission: isPendingPermission);
}
}
}
@@ -0,0 +1,28 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import "tool_bubble_base.dart";
class AdvisorBubble extends StatelessWidget {
const AdvisorBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final model = input["model"] as String? ?? "";
return ToolBubbleBase(
toolName: "Advisor",
icon: LucideIcons.brain,
result: result,
isPendingPermission: isPendingPermission,
detail: model,
);
}
}
@@ -0,0 +1,28 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import "tool_bubble_base.dart";
class BashBubble extends StatelessWidget {
const BashBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final command = input["command"] as String? ?? "";
return ToolBubbleBase(
toolName: "Bash",
icon: LucideIcons.terminal,
result: result,
isPendingPermission: isPendingPermission,
detail: command,
);
}
}
@@ -0,0 +1,43 @@
import "dart:convert";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "tool_bubble_base.dart";
class DefaultToolBubble extends StatelessWidget {
const DefaultToolBubble({
super.key,
required this.toolName,
this.input,
this.result,
this.isPendingPermission = false,
});
final String toolName;
final Map<String, dynamic>? input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ToolBubbleBase(
toolName: toolName,
icon: LucideIcons.wrench,
result: result,
isPendingPermission: isPendingPermission,
body: input != null && input!.isNotEmpty
? Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
const JsonEncoder.withIndent(" ").convert(input),
style: theme.typography.p.copyWith(
fontSize: 12,
color: theme.colorScheme.mutedForeground,
fontFamily: "monospace",
),
),
)
: null,
);
}
}
@@ -0,0 +1,39 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../../providers/chat_provider.dart";
import "../../../../utils/path_utils.dart";
import "../../diff_view.dart";
import "tool_bubble_base.dart";
class EditBubble extends StatelessWidget {
const EditBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final projectRoot = context.read<ChatProvider>().workingDirectory;
final filePath = input["file_path"] as String? ?? "";
final oldString = input["old_string"] as String? ?? "";
final newString = input["new_string"] as String? ?? "";
return ToolBubbleBase(
toolName: "Edit",
icon: LucideIcons.filePen,
result: result,
isPendingPermission: isPendingPermission,
detail: shortenPath(filePath, projectRoot),
body: DiffView(
oldString: oldString,
newString: newString,
),
);
}
}
@@ -0,0 +1,37 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../../providers/chat_provider.dart";
import "../../../../utils/path_utils.dart";
import "tool_bubble_base.dart";
class GlobBubble extends StatelessWidget {
const GlobBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final projectRoot = context.read<ChatProvider>().workingDirectory;
final pattern = input["pattern"] as String? ?? "";
final searchPath = input["path"] as String?;
final detail = searchPath != null && searchPath.isNotEmpty
? "${shortenPath(searchPath, projectRoot)}/$pattern"
: pattern;
return ToolBubbleBase(
toolName: "Glob",
icon: LucideIcons.folderSearch,
result: result,
isPendingPermission: isPendingPermission,
detail: detail,
);
}
}
@@ -0,0 +1,37 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../../providers/chat_provider.dart";
import "../../../../utils/path_utils.dart";
import "tool_bubble_base.dart";
class GrepBubble extends StatelessWidget {
const GrepBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final projectRoot = context.read<ChatProvider>().workingDirectory;
final pattern = input["pattern"] as String? ?? "";
final searchPath = input["path"] as String?;
final detail = searchPath != null && searchPath.isNotEmpty
? "${shortenPath(searchPath, projectRoot)}$pattern"
: pattern;
return ToolBubbleBase(
toolName: "Grep",
icon: LucideIcons.search,
result: result,
isPendingPermission: isPendingPermission,
detail: detail,
);
}
}
@@ -0,0 +1,32 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../../providers/chat_provider.dart";
import "../../../../utils/path_utils.dart";
import "tool_bubble_base.dart";
class ReadBubble extends StatelessWidget {
const ReadBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final projectRoot = context.read<ChatProvider>().workingDirectory;
final filePath = input["file_path"] as String? ?? "";
return ToolBubbleBase(
toolName: "Read",
icon: LucideIcons.fileText,
result: result,
isPendingPermission: isPendingPermission,
detail: shortenPath(filePath, projectRoot),
);
}
}
@@ -0,0 +1,145 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../../providers/chat_provider.dart";
import "../permission_decision.dart";
class ToolBubbleBase extends StatelessWidget {
const ToolBubbleBase({
super.key,
required this.toolName,
required this.icon,
this.detail,
this.body,
this.result,
this.isPendingPermission = false,
});
final String toolName;
final IconData icon;
final String? detail;
final Widget? body;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OutlinedContainer(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IntrinsicHeight(
child: Row(
children: [
Container(
color: theme.colorScheme.primary.scaleAlpha(0.5),
padding: EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
child: Row(
children: [
Icon(icon).iconSmall,
Gap(8),
Text(
toolName,
).textSmall,
],
),
),
VerticalDivider(),
if (detail != null)...[
Gap(16),
Text(
detail!,
).mono.xSmall
]
],
),
),
if (body != null) ...[
Divider(),
body!,
],
if (result != null) ...[
Divider(),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: SelectableText(
"\u200B${result!}",
style: TextStyle(
color: theme.colorScheme.mutedForeground,
),
).xSmall.mono,
)
]
],
),
),
if (isPendingPermission) ...[
Gap(8),
Row(
children: [
Expanded(
child: Button.outline(
leading: Icon(LucideIcons.check).iconSmall,
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.allowOnce),
child: Text("Allow").small,
),
),
Gap(8),
Expanded(
child: Button.outline(
leading: Icon(LucideIcons.checkCheck).iconSmall,
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.allowAlways),
child: Text("Allow always").small,
),
),
Gap(8),
Expanded(
child: Button.destructive(
leading: Icon(LucideIcons.x).iconSmall,
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.reject),
child: Text("Reject").small,
),
),
],
),
],
],
);
}
}
@@ -0,0 +1,28 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import "tool_bubble_base.dart";
class WebFetchBubble extends StatelessWidget {
const WebFetchBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final url = input["url"] as String? ?? "";
return ToolBubbleBase(
toolName: "WebFetch",
icon: LucideIcons.link,
result: result,
isPendingPermission: isPendingPermission,
detail: url,
);
}
}
@@ -0,0 +1,28 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import "tool_bubble_base.dart";
class WebSearchBubble extends StatelessWidget {
const WebSearchBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final query = input["query"] as String? ?? "";
return ToolBubbleBase(
toolName: "WebSearch",
icon: LucideIcons.globe,
result: result,
isPendingPermission: isPendingPermission,
detail: query,
);
}
}
@@ -0,0 +1,38 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../../providers/chat_provider.dart";
import "../../../../utils/path_utils.dart";
import "../../diff_view.dart";
import "tool_bubble_base.dart";
class WriteBubble extends StatelessWidget {
const WriteBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final projectRoot = context.read<ChatProvider>().workingDirectory;
final filePath = input["file_path"] as String? ?? "";
final content = input["content"] as String? ?? "";
return ToolBubbleBase(
toolName: "Write",
icon: LucideIcons.filePlus,
result: result,
isPendingPermission: isPendingPermission,
detail: shortenPath(filePath, projectRoot),
body: DiffView(
oldString: "",
newString: content,
),
);
}
}
@@ -0,0 +1,19 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
class UserBubble extends StatelessWidget {
const UserBubble({super.key, required this.content});
final String content;
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.centerRight,
child: OutlinedContainer(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
backgroundColor: Theme.of(context).colorScheme.border,
child: SelectableText(content),
),
);
}
}
+455
View File
@@ -0,0 +1,455 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import 'package:pasteboard/pasteboard.dart';
import 'package:flutter/services.dart';
import 'package:file_picker/file_picker.dart';
import 'package:provider/provider.dart';
import 'dart:io';
import '../../constants.dart';
import '../../models/attachment.dart';
import '../../providers/chat_provider.dart';
import '../../providers/home_coordinator.dart';
import '../../providers/session_provider.dart';
import '../../providers/settings_provider.dart';
import 'attachment_preview.dart';
import '../common/button.dart';
import 'model_picker_dialog.dart';
class ChatBox extends StatefulWidget {
const ChatBox({super.key});
@override
State<ChatBox> createState() => _ChatBoxState();
}
class _ChatBoxState extends State<ChatBox> {
late TextEditingController _controller;
late FocusNode _focusNode;
final List<Attachment> _attachments = [];
@override
void initState() {
super.initState();
_controller = TextEditingController();
_focusNode = FocusNode();
_controller.addListener(_onTextChanged);
}
Future<void> _onPastePressed() async {
try {
final filePaths = await Pasteboard.files();
if (filePaths.isNotEmpty && mounted) {
for (var filePath in filePaths) {
try {
final file = File(filePath);
final fileBytes = await file.readAsBytes();
final fileName = file.path.split('/').last;
if (mounted) {
setState(() {
_attachments.add(
Attachment(
name: fileName,
mimeType: _getMimeType(fileName, fileBytes),
data: fileBytes,
),
);
});
}
} catch (e) {
// skip files that cant be read
}
}
return;
}
} catch (e) {
// no files in clipboard
}
// fallback to raw image data (screenshots etc)
try {
final imageBytes = await Pasteboard.image;
if (imageBytes != null && mounted) {
final imageData = Uint8List.fromList(imageBytes);
setState(() {
_attachments.add(
Attachment(
name: 'image.png',
mimeType: _getMimeType('image.png', imageData),
data: imageData,
),
);
});
}
} catch (e) {
// no image in clipboard
}
}
String _getMimeType(String filename, Uint8List data) {
if (data.length >= 4) {
if (data[0] == 0x25 &&
data[1] == 0x50 &&
data[2] == 0x44 &&
data[3] == 0x46) {
return 'application/pdf';
}
if (data[0] == 0x89 &&
data[1] == 0x50 &&
data[2] == 0x4E &&
data[3] == 0x47) {
return 'image/png';
}
if (data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) {
return 'image/jpeg';
}
if (data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46) {
return 'image/gif';
}
if (data[0] == 0x52 &&
data[1] == 0x49 &&
data[2] == 0x46 &&
data[3] == 0x46) {
if (data.length >= 12 &&
data[8] == 0x57 &&
data[9] == 0x45 &&
data[10] == 0x42 &&
data[11] == 0x50) {
return 'image/webp';
}
}
}
final extension = filename.split('.').last.toLowerCase();
switch (extension) {
case 'pdf':
return 'application/pdf';
case 'txt':
return 'text/plain';
case 'json':
return 'application/json';
case 'csv':
return 'text/csv';
case 'md':
return 'text/markdown';
case 'png':
return 'image/png';
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'gif':
return 'image/gif';
case 'webp':
return 'image/webp';
default:
return 'application/octet-stream';
}
}
void _onTextChanged() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() {});
});
}
Future<void> _onAttachPressed() async {
final result = await FilePicker.platform.pickFiles(allowMultiple: true);
if (result == null || !mounted) return;
for (final file in result.files) {
if (file.path == null) continue;
try {
final f = File(file.path!);
final bytes = await f.readAsBytes();
if (!mounted) return;
setState(() {
_attachments.add(
Attachment(
name: file.name,
mimeType: _getMimeType(file.name, bytes),
data: bytes,
),
);
});
} catch (e) {
// skip unreadable files
}
}
}
Widget _left(BuildContext context) {
return SizedBox(
height: 38,
child: AspectRatio(
aspectRatio: 1,
child: AgcGhostButton(
borderRadius: BorderRadius.circular(Theme.of(context).radiusLg - 4),
onPressed: _onAttachPressed,
child: Icon(LucideIcons.paperclip),
),
),
);
}
void _openModelDialog(BuildContext context) async {
final settings = context.read<SettingsProvider>();
final session = context.read<SessionProvider>();
final selectedModel = settings.normalizeModelId(settings.settings.model);
final result = await showDialog<String>(
context: context,
builder: (context) => ModelPickerDialog(
models: selectableAiModels,
selectedModel: selectedModel,
),
);
if (result != null) {
await settings.updateModel(result);
await session.updateSessionModel(result);
}
}
Widget _right(BuildContext context) {
final settings = context.read<SettingsProvider>();
final selectedModel = settings.normalizeModelId(settings.settings.model);
return SizedBox(
height: 38,
child: Row(
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 150,
minHeight: double.infinity,
),
child: AgcGhostButton(
borderRadius: BorderRadius.circular(
Theme.of(context).radiusLg - 4,
),
onPressed: () => _openModelDialog(context),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
selectableAiModels
.where((m) => m.id == selectedModel)
.map((m) => m.label)
.firstOrNull ??
selectedModel,
overflow: TextOverflow.ellipsis,
).small,
),
Gap(8),
Icon(LucideIcons.chevronsUpDown),
],
),
),
),
),
Gap(8),
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),
),
),
],
),
);
}
Widget _buildLeading(BuildContext context, int numberOfLines) {
if (numberOfLines > 1) return SizedBox.shrink();
return _left(context);
}
Widget _buildTrailing(int numberOfLines) {
if (numberOfLines > 1) return SizedBox.shrink();
return _right(context);
}
Widget? _buildBottom(BuildContext context, int numberOfLines) {
if (numberOfLines <= 1) return null;
return Container(
margin: EdgeInsets.only(top: 8),
height: 32,
child: Row(children: [_left(context), Spacer(), _right(context)]),
);
}
String _fmtTokens(int n) {
final s = n.toString();
final buf = StringBuffer();
for (var i = 0; i < s.length; i++) {
if (i > 0 && (s.length - i) % 3 == 0) buf.write(",");
buf.write(s[i]);
}
return buf.toString();
}
void _removeAttachment(int index) {
setState(() {
_attachments.removeAt(index);
});
_focusNode.requestFocus();
}
@override
void dispose() {
_controller.removeListener(_onTextChanged);
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final chat = context.watch<ChatProvider>();
context
.watch<SettingsProvider>(); // needed so model label updates reactively
final queuedMessages = chat.queuedMessages;
final contextTokens = chat.contextTokens;
return Column(
children: [
LayoutBuilder(
builder: (context, constraints) {
final style = DefaultTextStyle.of(context).style;
const reservedForIcons = 34;
final painter = TextPainter(
text: TextSpan(text: _controller.text, style: style),
textDirection: TextDirection.ltr,
)..layout(maxWidth: constraints.maxWidth - reservedForIcons);
final numberOfLines = painter.computeLineMetrics().length;
return Focus(
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.keyV &&
(HardwareKeyboard.instance.isControlPressed ||
HardwareKeyboard.instance.isMetaPressed)) {
_onPastePressed();
}
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.enter) {
if (HardwareKeyboard.instance.isShiftPressed) {
final sel = _controller.selection;
final text = _controller.text;
final newText = text.replaceRange(sel.start, sel.end, '\n');
_controller.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(offset: sel.start + 1),
);
return KeyEventResult.handled;
} else {
final text = _controller.text.trim();
if (text.isNotEmpty) {
context.read<HomeCoordinator>().sendMessage(text);
_controller.clear();
}
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
child: OutlinedContainer(
child: ButtonGroup.vertical(
expands: true,
children: [
for (int i = 0; i < queuedMessages.length; i++) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 0,
),
child: Row(
children: [
Icon(
LucideIcons.cornerDownRight,
).iconSmall.iconMutedForeground,
Gap(14),
Expanded(
child: Text(queuedMessages[i]).small.textMuted,
),
IconButton.text(
onPressed: () => chat.removeQueuedMessage(i),
icon: const Icon(LucideIcons.trash2),
).iconSmall,
],
),
),
Divider(),
],
TextField(
controller: _controller,
focusNode: _focusNode,
borderRadius: Theme.of(context).borderRadiusLg,
placeholder: Text("Ask the agency anything"),
minLines: 1,
maxLines: numberOfLines > 1 ? 5 : 1,
clipBehavior: Clip.hardEdge,
padding: EdgeInsets.all(8),
features: [
if (_attachments.isNotEmpty)
InputFeature.above(
AttachmentPreview(
attachments: _attachments,
onRemove: _removeAttachment,
),
),
InputFeature.leading(
_buildLeading(context, numberOfLines),
),
InputFeature.trailing(_buildTrailing(numberOfLines)),
InputFeature.below(
_buildBottom(context, numberOfLines),
),
],
),
if (chat.isLoading)
SizedBox(
height: 4,
child: LinearProgressIndicator()
)
],
),
),
);
},
),
],
);
}
}
+383
View File
@@ -0,0 +1,383 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../src/session/session_types.dart";
import "../../providers/chat_provider.dart";
import "bubbles/assistant_bubble.dart";
import "bubbles/tool_bubble.dart";
import "bubbles/user_bubble.dart";
class ChatView extends StatefulWidget {
final ScrollController scrollController;
const ChatView({super.key, required this.scrollController});
@override
State<ChatView> createState() => _ChatViewState();
}
class _ChatViewState extends State<ChatView> {
ScrollController get _scrollController => widget.scrollController;
List<String> _previousMessageContents = [];
bool _isUserScrolling = false;
DateTime? _lastScrollTime;
bool _showJumpToBottom = false;
bool _hasNewMessagesWhileScrolledAway = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_handleScroll);
}
@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;
});
}
}
});
}
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;
}
}
}
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;
}
}
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,
);
},
),
),
),
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,
),
),
],
),
),
),
),
],
);
},
);
}
// merge consecutive tool call + result messages into single entries
List<_ChatEntry> _buildEntries(List<Message> messages) {
final result = <_ChatEntry>[];
int i = 0;
while (i < messages.length) {
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];
final nextFirst = next.content.split("\n").first.trim();
if (next.role == "tool" && nextFirst == "$toolName result") {
final body = next.content.indexOf("\n");
toolResult = body != -1 ? next.content.substring(body + 1).trim() : null;
i++;
}
}
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++;
} else {
result.add(_MessageEntry(msg));
i++;
}
}
return result;
}
}
sealed class _ChatEntry {}
class _MessageEntry extends _ChatEntry {
_MessageEntry(this.message);
final Message message;
}
class _ToolEntry extends _ChatEntry {
_ToolEntry({required this.toolName, this.toolInput, this.result});
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();
}
class _FullHeightScrollbarState extends State<FullHeightScrollbar> {
bool _hovering = false;
bool _scrolling = false;
DateTime _lastScroll = DateTime.fromMillisecondsSinceEpoch(0);
@override
void initState() {
super.initState();
widget.controller.addListener(_onScroll);
}
void _onScroll() {
_lastScroll = DateTime.now();
setState(() => _scrolling = true);
Future.delayed(const Duration(milliseconds: 800), () {
if (!mounted) return;
if (DateTime.now().difference(_lastScroll).inMilliseconds >= 800) {
setState(() => _scrolling = false);
}
});
}
@override
void dispose() {
widget.controller.removeListener(_onScroll);
super.dispose();
}
@override
Widget build(BuildContext context) {
final visible = _hovering || _scrolling;
return MouseRegion(
onEnter: (_) => setState(() => _hovering = true),
onExit: (_) => setState(() => _hovering = false),
child: AnimatedOpacity(
opacity: visible ? 1.0 : 0.0,
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 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),
),
),
),
],
);
},
),
),
);
}
}
+329
View File
@@ -0,0 +1,329 @@
import "package:diff_match_patch/diff_match_patch.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
const _contextLines = 3;
class DiffView extends StatelessWidget {
const DiffView({
super.key,
this.oldString,
this.newString,
this.content,
}) : assert(
content != null || (oldString != null && newString != null),
"Provide either content (view-only) or oldString+newString (diff)",
);
final String? oldString;
final String? newString;
// view-only mode — show content as plain code, no diff colors
final String? content;
@override
Widget build(BuildContext context) {
if (content != null) {
final lines = content!.split("\n");
final viewLines = [
for (int i = 0; i < lines.length; i++)
_DiffLine(_LineKind.context, lines[i], newLine: i + 1),
];
final hunk = _Hunk(oldStart: 1, newStart: 1, lines: viewLines);
return _HunkView(hunk: hunk);
}
final hunks = _computeHunks(oldString!, newString!);
if (hunks.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final hunk in hunks) ...[
// is first
if (hunk != hunks.first) ...[
Divider(),
Gap(1),
Divider(),
],
_HunkView(hunk: hunk)
],
],
);
}
}
// ─── data model ───────────────────────────────────────────────────────────────
enum _LineKind { context, added, removed }
class _DiffLine {
const _DiffLine(this.kind, this.text, {this.oldLine, this.newLine});
final _LineKind kind;
final String text;
final int? oldLine;
final int? newLine;
}
class _Hunk {
_Hunk({
required this.oldStart,
required this.newStart,
required this.lines,
});
final int oldStart;
final int newStart;
final List<_DiffLine> lines;
int get oldCount => lines.where((l) => l.kind != _LineKind.added).length;
int get newCount => lines.where((l) => l.kind != _LineKind.removed).length;
}
// ─── diff computation ─────────────────────────────────────────────────────────
List<_Hunk> _computeHunks(String oldStr, String newStr) {
final dmp = DiffMatchPatch();
final oldLines = oldStr.split("\n");
final newLines = newStr.split("\n");
// encode lines → single chars so dmp does line-level diff
final enc = _encodeLines(oldLines, newLines);
final diffs = dmp.diff(enc.oldEncoded, enc.newEncoded, false);
dmp.diffCleanupSemantic(diffs);
// expand diffs back to line sequences
final rawLines = <_DiffLine>[];
int oldIdx = 0;
int newIdx = 0;
for (final d in diffs) {
final count = d.text.length; // each char == one line
switch (d.operation) {
case DIFF_EQUAL:
for (int i = 0; i < count; i++) {
rawLines.add(_DiffLine(
_LineKind.context,
enc.lines[d.text.codeUnitAt(i) - 0xE000],
oldLine: oldIdx + 1,
newLine: newIdx + 1,
));
oldIdx++;
newIdx++;
}
break;
case DIFF_DELETE:
for (int i = 0; i < count; i++) {
rawLines.add(_DiffLine(
_LineKind.removed,
enc.lines[d.text.codeUnitAt(i) - 0xE000],
oldLine: oldIdx + 1,
));
oldIdx++;
}
break;
case DIFF_INSERT:
for (int i = 0; i < count; i++) {
rawLines.add(_DiffLine(
_LineKind.added,
enc.lines[d.text.codeUnitAt(i) - 0xE000],
newLine: newIdx + 1,
));
newIdx++;
}
break;
}
}
return _groupIntoHunks(rawLines);
}
// keep only context lines that are within _contextLines of a change
List<_Hunk> _groupIntoHunks(List<_DiffLine> rawLines) {
final n = rawLines.length;
// mark which context lines to keep
final keep = List<bool>.filled(n, false);
for (int i = 0; i < n; i++) {
if (rawLines[i].kind != _LineKind.context) {
for (int j = (i - _contextLines).clamp(0, n - 1);
j <= (i + _contextLines).clamp(0, n - 1);
j++) {
keep[j] = true;
}
}
}
final hunks = <_Hunk>[];
int i = 0;
while (i < n) {
if (!keep[i]) {
i++;
continue;
}
// start of a new hunk
final hunkLines = <_DiffLine>[];
int oldStart = rawLines[i].oldLine ?? 1;
int newStart = rawLines[i].newLine ?? 1;
while (i < n && keep[i]) {
hunkLines.add(rawLines[i]);
i++;
}
hunks.add(_Hunk(
oldStart: oldStart,
newStart: newStart,
lines: hunkLines,
));
}
return hunks;
}
// line encoding — maps unique lines to single unicode chars starting at U+E000
class _LineEncoding {
final List<String> lines; // index → line text
final String oldEncoded;
final String newEncoded;
const _LineEncoding(this.lines, this.oldEncoded, this.newEncoded);
}
_LineEncoding _encodeLines(List<String> oldLines, List<String> newLines) {
final lineIndex = <String, int>{};
final lines = <String>[];
String encode(List<String> src) {
final buf = StringBuffer();
for (final line in src) {
if (!lineIndex.containsKey(line)) {
lineIndex[line] = lines.length;
lines.add(line);
}
buf.writeCharCode(0xE000 + lineIndex[line]!);
}
return buf.toString();
}
final oldEncoded = encode(oldLines);
final newEncoded = encode(newLines);
return _LineEncoding(lines, oldEncoded, newEncoded);
}
// ─── widgets ──────────────────────────────────────────────────────────────────
String _hunkSummary(_Hunk hunk) {
final added = hunk.lines.where((l) => l.kind == _LineKind.added).length;
final removed = hunk.lines.where((l) => l.kind == _LineKind.removed).length;
final parts = <String>[];
if (added > 0) parts.add("Added $added ${added == 1 ? 'line' : 'lines'}");
if (removed > 0) parts.add("removed $removed ${removed == 1 ? 'line' : 'lines'}");
return parts.join(", ");
}
class _HunkView extends StatelessWidget {
const _HunkView({required this.hunk});
final _Hunk hunk;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// hunk header
Container(
// color: theme.colorScheme.muted.withValues(alpha: 0.4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Text(
_hunkSummary(hunk),
style: TextStyle(color: theme.colorScheme.mutedForeground),
).xSmall.mono,
),
Divider(),
for (final line in hunk.lines) _LineView(line: line),
],
);
}
}
class _LineView extends StatelessWidget {
const _LineView({required this.line});
final _DiffLine line;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final Color bg;
final Color fg;
final String prefix;
switch (line.kind) {
case _LineKind.added:
bg = const Color(0xFF166534).withValues(alpha: 0.2);
fg = const Color(0xFF4ADE80);
prefix = "+";
break;
case _LineKind.removed:
bg = const Color(0xFF991B1B).withValues(alpha: 0.2);
fg = const Color(0xFFF87171);
prefix = "-";
break;
case _LineKind.context:
bg = Colors.transparent;
fg = theme.colorScheme.mutedForeground;
prefix = " ";
break;
}
final numColor = theme.colorScheme.mutedForeground.withValues(alpha: 0.5);
final lineNum = line.kind == _LineKind.removed ? line.oldLine : line.newLine;
return Container(
color: bg,
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 32,
child: Text(
lineNum != null ? "$lineNum" : "",
textAlign: TextAlign.right,
style: TextStyle(color: numColor),
).mono.xSmall,
),
const SizedBox(width: 8),
SizedBox(
width: 10,
child: Text(prefix, style: TextStyle(color: fg)).mono.xSmall,
),
const SizedBox(width: 4),
Expanded(
child: Text(line.text, style: TextStyle(color: fg)).mono.xSmall,
),
],
),
);
}
}
@@ -2,12 +2,21 @@ import "package:flutter/src/material/theme_data.dart";
import "package:flutter_markdown/flutter_markdown.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../src/session/session_types.dart";
import "../../../src/permissions/permission_types.dart";
import "../../../src/session/session_types.dart";
import "advisor_message.dart";
import "../common/button.dart";
class MessageBubble extends StatelessWidget {
const MessageBubble({required this.message});
const MessageBubble({
required this.message,
this.isPendingPermission = false,
this.onPermissionDecision,
});
final Message message;
final bool isPendingPermission;
final void Function(PermissionDecision)? onPermissionDecision;
@override
Widget build(BuildContext context) {
@@ -19,23 +28,21 @@ class MessageBubble extends StatelessWidget {
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),
),
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),
),
),
);
} else if (isAssistant) {
return MarkdownBody(
@@ -48,27 +55,66 @@ class MessageBubble extends StatelessWidget {
final lines = message.content.split("\n");
final title = lines.first.trim();
final isAdvisor = title.startsWith("Advisor");
return Row(
if (isAdvisor) {
final body = lines.skip(1).join("\n").trim();
return AdvisorMessage(title: title, body: body);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 10,
width: 10,
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle
),
Row(
children: [
OutlinedContainer(
padding: const EdgeInsets.all(10),
backgroundColor: theme.colorScheme.primary,
child: Icon(LucideIcons.wrench).iconSmall,
),
Gap(8),
Expanded(
child: Text(
title,
style: theme.typography.p.copyWith(fontSize: 13),
),
),
],
),
Gap(8),
if (isPendingPermission) ...[
Gap(8),
Row(
children: [
Text(
title,
style: theme.typography.p.copyWith(
fontSize: 13
AgcSecondaryButton(
onPressed: () => onPermissionDecision?.call(PermissionDecision.allowOnce),
child: Text("Allow").small,
),
Gap(8),
AgcGhostButton(
onPressed: () => onPermissionDecision?.call(PermissionDecision.allowAlways),
child: Text("Allow always").small,
),
Gap(8),
AgcGhostButton(
onPressed: () => onPermissionDecision?.call(PermissionDecision.reject),
child: Text("Reject").small,
),
],
),
),
],
],
);
}
@@ -1,8 +1,8 @@
import "package:flutter/material.dart";
import "package:provider/provider.dart";
import "../../src/api/openrouter_client.dart";
import "../providers/settings_provider.dart";
import "../../../src/api/openrouter_client.dart";
import "../../providers/settings_provider.dart";
class ModelPicker extends StatefulWidget {
const ModelPicker();
@@ -0,0 +1,103 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../constants.dart";
class ModelPickerDialog extends StatefulWidget {
final List<SelectableAiModel> models;
final String? selectedModel;
const ModelPickerDialog({
super.key,
required this.models,
this.selectedModel,
});
@override
State<ModelPickerDialog> createState() => _ModelPickerDialogState();
}
class _ModelPickerDialogState extends State<ModelPickerDialog> {
late TextEditingController _searchController;
String _query = '';
@override
void initState() {
super.initState();
_searchController = TextEditingController();
_searchController.addListener(() {
setState(() => _query = _searchController.text.trim().toLowerCase());
});
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
List<SelectableAiModel> get _filtered {
if (_query.isEmpty) return widget.models;
return widget.models.where((m) =>
m.label.toLowerCase().contains(_query) ||
m.id.toLowerCase().contains(_query)
).toList();
}
@override
Widget build(BuildContext context) {
final filtered = _filtered;
return AlertDialog(
title: const Text('Select model'),
content: SizedBox(
width: 340,
height: 380,
child: Column(
children: [
TextField(
controller: _searchController,
autofocus: true,
placeholder: const Text('Search models...'),
features: const [InputFeature.clear()],
),
Gap(8),
Expanded(
child: filtered.isEmpty
? Center(child: Text("No results").muted)
: ListView.builder(
itemCount: filtered.length,
itemBuilder: (context, i) {
final model = filtered[i];
final isSelected = model.id == widget.selectedModel;
return SizedBox(
width: double.infinity,
child: Button(
style: isSelected ? ButtonStyle.secondary() : ButtonStyle.ghost(),
disableFocusOutline: true,
onPressed: () => Navigator.of(context).pop(model.id),
child: Align(
alignment: Alignment.centerLeft,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(model.label),
Text(model.id).muted.small,
],
),
),
),
);
},
),
),
],
),
),
);
}
}
-205
View File
@@ -1,205 +0,0 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../providers/chat_provider.dart";
import "message_bubble.dart";
class ChatView extends StatefulWidget {
const ChatView();
@override
State<ChatView> createState() => _ChatViewState();
}
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 _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, _) {
// 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;
}
}
// 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,
),
),
],
),
),
),
),
],
);
},
);
}
}
+233
View File
@@ -0,0 +1,233 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
class AgcGhostButton extends StatefulWidget {
final Widget child;
final VoidCallback? onPressed;
final BorderRadius? borderRadius;
AgcGhostButton({
required this.child,
this.onPressed,
this.borderRadius,
});
@override
State<AgcGhostButton> createState() => _GhostButtonState();
}
class _GhostButtonState extends State<AgcGhostButton> {
bool _hovering = false;
bool _pressing = false;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final radius = widget.borderRadius ?? BorderRadius.circular(
Theme.of(context).radiusSm - 4
);
Color bg = Colors.transparent;
if (_pressing) {
bg = colorScheme.accent.withOpacity(0.8);
} else if (_hovering) {
bg = colorScheme.accent.withOpacity(0.5);
}
return MouseRegion(
cursor: widget.onPressed != null ? SystemMouseCursors.click : MouseCursor.defer,
onEnter: (_) => setState(() => _hovering = true),
onExit: (_) => setState(() {
_hovering = false;
_pressing = false;
}),
child: GestureDetector(
onTapDown: (_) => setState(() => _pressing = true),
onTapUp: (_) {
setState(() => _pressing = false);
if (widget.onPressed != null) widget.onPressed!();
},
onTapCancel: () => setState(() => _pressing = false),
child: AnimatedContainer(
duration: Duration(milliseconds: 80),
decoration: BoxDecoration(
color: bg,
borderRadius: radius,
),
padding: EdgeInsets.all(4),
child: widget.child,
),
),
);
}
}
class AgcSecondaryButton extends StatefulWidget {
final Widget child;
final VoidCallback? onPressed;
final BorderRadius? borderRadius;
final bool enabled;
AgcSecondaryButton({
required this.child,
this.onPressed,
this.borderRadius,
this.enabled = true,
});
@override
State<AgcSecondaryButton> createState() => _SecondaryButtonState();
}
class _SecondaryButtonState extends State<AgcSecondaryButton> {
bool _hovering = false;
bool _pressing = false;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final radius = widget.borderRadius ?? BorderRadius.circular(
Theme.of(context).radiusSm
);
final bool active = widget.enabled && widget.onPressed != null;
Color bg = colorScheme.secondary;
if (!active) {
bg = colorScheme.secondary.withOpacity(0.4);
} else if (_pressing) {
bg = colorScheme.secondary.withOpacity(0.75);
} else if (_hovering) {
bg = colorScheme.secondary.withOpacity(0.85);
}
return MouseRegion(
cursor: active ? SystemMouseCursors.click : MouseCursor.defer,
onEnter: (_) { if (active) setState(() => _hovering = true); },
onExit: (_) => setState(() {
_hovering = false;
_pressing = false;
}),
child: GestureDetector(
onTapDown: active ? (_) => setState(() => _pressing = true) : null,
onTapUp: active ? (_) {
setState(() => _pressing = false);
widget.onPressed!();
} : null,
onTapCancel: active ? () => setState(() => _pressing = false) : null,
child: AnimatedContainer(
duration: Duration(milliseconds: 80),
decoration: BoxDecoration(
color: bg,
borderRadius: radius,
),
padding: EdgeInsets.all(4),
child: DefaultTextStyle.merge(
style: TextStyle(color: colorScheme.secondaryForeground),
child: IconTheme.merge(
data: IconThemeData(color: colorScheme.secondaryForeground),
child: widget.child,
),
),
),
),
);
}
}
class AgcOutlinedButton extends StatefulWidget {
final Widget child;
final VoidCallback? onPressed;
final BorderRadius? borderRadius;
final bool enabled;
AgcOutlinedButton({
required this.child,
this.onPressed,
this.borderRadius,
this.enabled = true,
});
@override
State<AgcOutlinedButton> createState() => _OutlinedButtonState();
}
class _OutlinedButtonState extends State<AgcOutlinedButton> {
bool _hovering = false;
bool _pressing = false;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final radius = widget.borderRadius ?? BorderRadius.circular(
Theme.of(context).radiusSm
);
final bool active = widget.enabled && widget.onPressed != null;
Color bg = Colors.transparent;
if (_pressing && active) {
bg = colorScheme.accent.withOpacity(0.6);
} else if (_hovering && active) {
bg = colorScheme.accent.withOpacity(0.35);
}
final borderColor = active
? colorScheme.border
: colorScheme.border.withOpacity(0.4);
return MouseRegion(
cursor: active ? SystemMouseCursors.click : MouseCursor.defer,
onEnter: (_) { if (active) setState(() => _hovering = true); },
onExit: (_) => setState(() {
_hovering = false;
_pressing = false;
}),
child: GestureDetector(
onTapDown: active ? (_) => setState(() => _pressing = true) : null,
onTapUp: active ? (_) {
setState(() => _pressing = false);
widget.onPressed!();
} : null,
onTapCancel: active ? () => setState(() => _pressing = false) : null,
child: AnimatedContainer(
duration: Duration(milliseconds: 80),
decoration: BoxDecoration(
color: bg,
borderRadius: radius,
border: Border.all(color: borderColor),
),
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: DefaultTextStyle.merge(
style: TextStyle(
color: active ? colorScheme.foreground : colorScheme.mutedForeground,
),
child: IconTheme.merge(
data: IconThemeData(
color: active ? colorScheme.foreground : colorScheme.mutedForeground,
),
child: widget.child,
),
),
),
),
);
}
}
+147
View File
@@ -0,0 +1,147 @@
import "package:flutter/widgets.dart" hide Tooltip;
import "package:shadcn_flutter/shadcn_flutter.dart" hide Row, Expanded;
import "../../providers/chat_provider.dart";
import "../../providers/cost_provider.dart";
import "../../providers/settings_provider.dart";
import "package:provider/provider.dart";
String _fmtTokens(int n) {
final s = n.toString();
final buf = StringBuffer();
for (var i = 0; i < s.length; i++) {
if (i > 0 && (s.length - i) % 3 == 0) buf.write(",");
buf.write(s[i]);
}
return buf.toString();
}
class FooterBar extends StatelessWidget {
const FooterBar({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mutedFg = theme.colorScheme.mutedForeground;
final borderColor = theme.colorScheme.border;
final bg = theme.colorScheme.muted.scaleAlpha(0.3);
final costProvider = context.watch<CostProvider>();
final settingsProvider = context.watch<SettingsProvider>();
final chatProvider = context.watch<ChatProvider>();
final model = settingsProvider.settings.model ?? "unknown";
final costUsd = costProvider.getTotalCostUsd();
final cost = "\$${costUsd.toStringAsFixed(4)}";
final inputToks = costProvider.getTotalInputTokens();
final outputToks = costProvider.getTotalOutputTokens();
final isLoading = chatProvider.isLoading;
final contextTokens = chatProvider.contextTokens;
final textStyle = TextStyle(
fontFamily: "monospace",
fontSize: 11,
height: 1,
fontWeight: FontWeight.w600,
color: mutedFg,
);
Widget divider() => const Padding(
padding: EdgeInsets.symmetric(horizontal: 5),
child: SizedBox(height: 12, child: VerticalDivider(width: 1)),
);
Widget copyrightBlock() {
return Text(
"© 2026 IMBENJI.NET LTD - The Agency",
style: textStyle,
);
}
Widget statusBlock() {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
isLoading ? "running..." : "idle",
style: textStyle.copyWith(
color: isLoading
? theme.colorScheme.primary
: mutedFg,
),
),
divider(),
Text(model.split("/").last, style: textStyle),
],
);
}
Widget statsBlock() {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (contextTokens > 0) ...[
Text(_fmtTokens(contextTokens), style: textStyle),
Text(" tokens", style: textStyle),
divider(),
],
Tooltip(
tooltip: (_) => TooltipContainer(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Text(
"In: $inputToks\nOut: $outputToks",
style: const TextStyle(
fontFamily: "monospace",
fontSize: 11,
height: 1.2,
),
),
),
child: Text(cost, style: textStyle),
),
],
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
width: double.infinity,
decoration: BoxDecoration(
color: bg,
border: Border(top: BorderSide(color: borderColor, width: 1)),
),
child: Row(
children: [
Expanded(child: Row(children: [copyrightBlock()])),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [statusBlock()],
),
),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [statsBlock()],
),
),
],
),
);
}
}
@@ -1,8 +1,8 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../providers/settings_provider.dart";
import "model_picker.dart";
import "../../providers/settings_provider.dart";
import "../chat/model_picker.dart";
class SettingsSheet extends StatelessWidget {
const SettingsSheet();
+32
View File
@@ -0,0 +1,32 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
class AppLogo extends StatelessWidget {
const AppLogo({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
"THE AGENCY",
style: TextStyle(
fontSize: 32,
height: 1,
fontWeight: FontWeight.w900,
),
),
Text(
"by IMBENJI.NET LTD",
style: TextStyle(
fontSize: 12,
height: 1,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.mutedForeground,
),
),
],
);
}
}
+112
View File
@@ -0,0 +1,112 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import '../../utils/format_relative_time.dart';
class ProjectButton extends StatefulWidget {
final String label;
final VoidCallback? onPressed;
final DateTime? lastMessage;
final bool collapsed;
ProjectButton({
required this.label,
this.onPressed,
this.lastMessage,
this.collapsed = false,
});
@override
State<ProjectButton> createState() => ProjectButtonState();
}
class ProjectButtonState extends State<ProjectButton> with TickerProviderStateMixin {
bool _isHovering = false;
late AnimationController _chevronController;
@override
void initState() {
super.initState();
_chevronController = AnimationController(
duration: Duration(milliseconds: 100),
vsync: this,
);
if (!widget.collapsed) {
_chevronController.forward();
}
}
@override
void didUpdateWidget(ProjectButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.collapsed != widget.collapsed) {
if (widget.collapsed) {
_chevronController.reverse();
} else {
_chevronController.forward();
}
}
}
@override
void dispose() {
_chevronController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
ColorScheme colorScheme = Theme.of(context).colorScheme;
return SizedBox(
width: double.infinity,
child: Button(
style: ButtonStyle.ghost().copyWith(
padding: (context, state, edgeInsets) {
return EdgeInsets.only(
top: 8,
left: 12,
bottom: 8,
right: 12
);
}
),
disableFocusOutline: true,
onPressed: () {
if (widget.onPressed != null) {
widget.onPressed!();
}
},
onHover: (isHovering) {
setState(() {
_isHovering = isHovering;
});
},
leading: !_isHovering ? Icon(
!widget.collapsed ? LucideIcons.folderOpen : LucideIcons.folderClosed
).iconSmall : RotationTransition(
turns: Tween(begin: 0.0, end: 0.25).animate(_chevronController),
child: Icon(
LucideIcons.chevronRight,
color: colorScheme.mutedForeground,
).iconSmall,
),
trailingGap: 32,
trailing: widget.lastMessage != null ?
Text(
formatRelativeTime(widget.lastMessage!)
).muted : null,
child: Text(
widget.label,
style: TextStyle(
color: colorScheme.mutedForeground
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
).small,
),
);
}
}
@@ -0,0 +1,98 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import 'project_button.dart';
class ProjectSection extends StatefulWidget {
final String projectLabel;
final List<Widget> children;
final VoidCallback? onHeaderPressed;
ProjectSection({
required this.projectLabel,
this.children = const [],
this.onHeaderPressed,
});
@override
State<ProjectSection> createState() => ProjectSectionState();
}
class ProjectSectionState extends State<ProjectSection> with TickerProviderStateMixin {
bool _isCollapsed = true;
late AnimationController _sizeController;
late AnimationController _fadeController;
@override
void initState() {
super.initState();
_sizeController = AnimationController(
duration: Duration(milliseconds: 150),
vsync: this,
);
_fadeController = AnimationController(
duration: Duration(milliseconds: 250),
vsync: this,
);
if (!_isCollapsed) {
_sizeController.forward();
_fadeController.forward();
}
}
@override
void dispose() {
_sizeController.dispose();
_fadeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
ProjectButton(
label: widget.projectLabel,
collapsed: _isCollapsed,
onPressed: () {
setState(() {
_isCollapsed = !_isCollapsed;
if (_isCollapsed) {
_fadeController.reverse();
_sizeController.reverse();
} else {
_fadeController.forward();
_sizeController.forward();
}
});
widget.onHeaderPressed?.call();
},
),
Gap(2),
ClipRect(
child: Align(
alignment: Alignment.topCenter,
child: FadeTransition(
opacity: _fadeController,
child: SizeTransition(
sizeFactor: _sizeController,
child: Column(
spacing: 2,
children: [
...widget.children
],
),
),
),
),
)
],
);
}
}
+175
View File
@@ -0,0 +1,175 @@
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 'app_logo.dart';
import 'project_section.dart';
import 'thread_button.dart';
class Sidebar extends StatelessWidget {
const Sidebar({super.key});
@override
Widget build(BuildContext context) {
final projectsProvider = context.watch<ProjectsProvider>();
final sessionProvider = context.watch<SessionProvider>();
final chatProvider = context.watch<ChatProvider>();
final coordinator = context.read<HomeCoordinator>();
return Container(
width: 300,
color: Theme.of(context).colorScheme.input.scaleAlpha(0.3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 100,
alignment: Alignment.centerLeft,
padding: EdgeInsets.symmetric(horizontal: 16),
child: AppLogo(),
),
Divider(),
Gap(16),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: _fixedSection(context, coordinator, chatProvider),
),
Gap(16),
Divider(),
Gap(16),
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: _projectsSection(context, projectsProvider, sessionProvider, coordinator),
),
),
],
),
);
}
Widget _fixedSection(BuildContext context, HomeCoordinator coordinator, ChatProvider chatProvider) {
return Column(
children: [
SizedBox(
width: double.infinity,
child: Button.ghost(
style: ButtonStyle.ghost().copyWith(
padding: (context, state, edgeInsets) {
return EdgeInsets.only(
top: 8,
left: 8,
bottom: 8,
right: 10
);
}
),
onPressed: chatProvider.isLoading ? null : coordinator.createNewChat,
disableFocusOutline: true,
leading: Icon(LucideIcons.squarePen).iconSmall,
alignment: Alignment.centerLeft,
child: Text("New Chat"),
),
),
SizedBox(
width: double.infinity,
child: Button.ghost(
style: ButtonStyle.ghost().copyWith(
padding: (context, state, edgeInsets) {
return EdgeInsets.only(
top: 8,
left: 8,
bottom: 8,
right: 10
);
}
),
onPressed: coordinator.pickProjectDirectory,
disableFocusOutline: true,
leading: Icon(LucideIcons.folderPlus).iconSmall,
alignment: Alignment.centerLeft,
child: Text("New Project"),
),
),
],
);
}
Widget _projectsSection(BuildContext context, ProjectsProvider projectsProvider, SessionProvider sessionProvider, HomeCoordinator coordinator) {
if (projectsProvider.projects.isEmpty) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Text("No projects yet").textSmall.muted,
);
}
// group sessions by working directory
final sessionsByProject = <String, List<SessionSummary>>{};
for (final session in sessionProvider.sessions) {
final dir = session.workingDirectory ?? '';
sessionsByProject.putIfAbsent(dir, () => []).add(session);
}
// sort sessions within each project newest first
final sorted = <String, List<SessionSummary>>{};
sessionsByProject.forEach((dir, sessions) {
sorted[dir] = List<SessionSummary>.from(sessions)
..sort((a, b) => b.updated.compareTo(a.updated));
});
return ListView(
padding: EdgeInsets.zero,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text("Projects").textMuted,
),
Gap(8),
for (final project in projectsProvider.projects) ...[
ProjectSection(
projectLabel: project.name,
children: [
if (sorted[project.workingDirectory]?.isEmpty ?? true)
ThreadButton(
label: "No threads yet",
muted: true,
)
else
for (final session in sorted[project.workingDirectory]!)
ThreadButton(
label: session.name,
lastMessage: session.updated,
selected: sessionProvider.currentSessionId == session.id,
onPressed: () => coordinator.openSession(session),
onDelete: () => coordinator.deleteSession(session),
),
],
),
Gap(2),
],
],
);
}
}
+81
View File
@@ -0,0 +1,81 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import '../../utils/format_relative_time.dart';
class ThreadButton extends StatelessWidget {
final String label;
final IconData? icon;
final VoidCallback? onPressed;
final VoidCallback? onDelete;
final DateTime? lastMessage;
final bool selected;
final bool muted;
ThreadButton({
required this.label,
this.icon,
this.onPressed,
this.onDelete,
this.lastMessage,
this.selected = false,
this.muted = false,
});
@override
Widget build(BuildContext context) {
ButtonStyle style = selected ? ButtonStyle.secondary() : ButtonStyle.ghost();
ColorScheme colorScheme = Theme.of(context).colorScheme;
final button = SizedBox(
width: double.infinity,
child: Button(
style: style.copyWith(
padding: (context, state, edgeInsets) {
return EdgeInsets.only(
top: 8,
left: 12,
bottom: 8,
right: 12
);
}
),
disableFocusOutline: true,
onPressed: onPressed ?? () {},
enabled: onPressed != null,
leading: Icon(
icon,
color: icon == null ? Colors.transparent : (muted ? colorScheme.mutedForeground : null),
).iconSmall,
trailingGap: 32,
trailing: lastMessage != null ?
Text(
formatRelativeTime(lastMessage!)
).muted.small.light : null,
child: Text(
label,
style: TextStyle(
color: (muted ? colorScheme.mutedForeground : null)
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
).small.light,
),
);
if (onDelete == null) return button;
return ContextMenu(
items: [
MenuButton(
onPressed: (_) => onDelete!(),
child: const Text("Delete"),
),
],
child: button,
);
}
}